| @@ -3481,8 +3481,16 @@ | ||
| 3481 | 3481 | * markup across the site, so they require a site response-cache purge. |
| 3482 | 3482 | * Content saves also require this scope while their local operation is a |
| 3483 | 3483 | * complete bucket sweep. |
| 3484 | 3484 | * |
| 3485 | + * A new term is `content`, not `presentation`. It has no posts yet, so no | |
| 3486 | + * page renders it until a post is saved with it, and that save is its own | |
| 3487 | + * content purge. Classed as presentation, it cleared the host's whole | |
| 3488 | + * nginx cache every time a post was published with a tag that did not | |
| 3489 | + * exist yet, which is most publishing. Renaming or deleting a term stays | |
| 3490 | + * presentation: the new name shows on every post in the term, and Nginx | |
| 3491 | + * Helper purges only the homepage for either. (QA #448) | |
| 3492 | + * | |
| 3485 | 3493 | * @return array{scope:string,intent:string,urls:array<int,string>} |
| 3486 | 3494 | */ |
| 3487 | 3495 | private static function invalidation_for_hook( string $hook ): array { |
| 3488 | 3496 | $presentation = array( |
| @@ -3488,9 +3496,8 @@ | ||
| 3488 | 3496 | $presentation = array( |
| 3489 | 3497 | 'switch_theme', |
| 3490 | 3498 | 'activated_plugin', |
| 3491 | 3499 | 'deactivated_plugin', |
| 3492 | - 'created_term', | |
| 3493 | 3500 | 'edited_term', |
| 3494 | 3501 | 'delete_term', |
| 3495 | 3502 | 'wp_update_nav_menu', |
| 3496 | 3503 | ); |
| @@ -5561,8 +5568,25 @@ | ||
| 5561 | 5568 | // True when an edge cache (Cloudflare) fronts the origin, so hits are |
| 5562 | 5569 | // absorbed before reaching PHP. The dashboard labels the ratio |
| 5563 | 5570 | // "origin-layer only" instead of implying it's the full picture. (#118) |
| 5564 | 5571 | 'edge_cache' => self::edge_cache_detected(), |
| 5572 | + // LiteSpeed Static Fast Path (#509): the web server serves hits | |
| 5573 | + // with no PHP, no way to tag them, and no way to count them. The | |
| 5574 | + // dashboard labels the ratio as PHP-layer only so a low number | |
| 5575 | + // reads as the trade the user chose, not a fault. | |
| 5576 | + // | |
| 5577 | + // rewrite_installed() is part of the condition (QA on #513): when | |
| 5578 | + // the .htaccess write failed (read-only file), hits still take | |
| 5579 | + // the drop-in path and ARE counted — the disclosure would be the | |
| 5580 | + // opposite of the truth. Health carries the "block missing" | |
| 5581 | + // warning for that state; this flag only speaks when static | |
| 5582 | + // serving is genuinely in effect. | |
| 5583 | + 'static_hits_uncounted' => ( | |
| 5584 | + Server::LITESPEED === Server::type() | |
| 5585 | + && ! empty( Settings::get()['cache_enabled'] ) | |
| 5586 | + && self::static_rewrite_allowed() | |
| 5587 | + && self::rewrite_installed() | |
| 5588 | + ), | |
| 5565 | 5589 | /* |
| 5566 | 5590 | * Whether the page cache is actually SERVING, as opposed to |
| 5567 | 5591 | * switched on in settings. The hero read the setting alone and |
| 5568 | 5592 | * announced "Active — serving cached HTML"; a site whose |
| @@ -6471,8 +6495,28 @@ | ||
| 6471 | 6495 | $stored = get_option( Settings_Manager::OPTION_PREFIX . 'cache', array() ); |
| 6472 | 6496 | return is_array( $stored ) ? $stored : array(); |
| 6473 | 6497 | } |
| 6474 | 6498 | |
| 6499 | + /** | |
| 6500 | + * Strict truthiness for the LiteSpeed Static Fast Path opt-in. | |
| 6501 | + * | |
| 6502 | + * On non-LiteSpeed servers the key is out of the schema and carried by | |
| 6503 | + * preserved_keys(), so a REST/MCP write lands VERBATIM — QA on #513 | |
| 6504 | + * stored the string "false" on Apache and the fast path installed | |
| 6505 | + * itself the moment the site moved to LiteSpeed, because | |
| 6506 | + * empty("false") is false. Only an explicit, unambiguous "yes" may | |
| 6507 | + * enable a path that trades away hit tagging; any other value — | |
| 6508 | + * "false", "no", arbitrary junk — stays OFF, which is the default the | |
| 6509 | + * user never left. | |
| 6510 | + */ | |
| 6511 | + private static function litespeed_optin_enabled( $value ): bool { | |
| 6512 | + if ( true === $value || 1 === $value ) { | |
| 6513 | + return true; | |
| 6514 | + } | |
| 6515 | + return is_string( $value ) | |
| 6516 | + && in_array( strtolower( trim( $value ) ), array( '1', 'true', 'on', 'yes' ), true ); | |
| 6517 | + } | |
| 6518 | + | |
| 6475 | 6519 | public static function sync_mobile_flag( $enabled = null ): void { |
| 6476 | 6520 | if ( null === $enabled ) { |
| 6477 | 6521 | $stored = self::stored_cache_opts(); |
| 6478 | 6522 | $enabled = ! empty( $stored['mobile_separate'] ); |
| @@ -6645,12 +6689,24 @@ | ||
| 6645 | 6689 | * the truth there. (Apache keeps the static fast path — it honors the |
| 6646 | 6690 | * header.) See maybe_emit_lscache_headers() for the paired LSCache |
| 6647 | 6691 | * stand-down that stops LiteSpeed's own module from shadowing the |
| 6648 | 6692 | * drop-in. |
| 6693 | + * | |
| 6694 | + * Opt-in (#509): `litespeed_static_rewrite` re-enables the fast path on | |
| 6695 | + * LiteSpeed for users who value raw TTFB over hit accounting. The trade | |
| 6696 | + * is stated in the setting's copy: statically served hits carry no | |
| 6697 | + * X-XSpeed-Cache header and are not counted (LiteSpeed logs the | |
| 6698 | + * original request line, so even the access-log scan cannot see | |
| 6699 | + * them — see Hit_Counter::collect_server_log_hits()). The drop-in | |
| 6700 | + * default above stays — nobody is surprised into an unverifiable cache. | |
| 6649 | 6701 | */ |
| 6650 | 6702 | public static function static_rewrite_allowed(): bool { |
| 6651 | - // LiteSpeed: drop-in serves hits (visible + counted) — see docblock. | |
| 6652 | - if ( Server::LITESPEED === Server::type() ) { | |
| 6703 | + // Stored read — reached from boot(); see stored_cache_opts(). | |
| 6704 | + $opts = self::stored_cache_opts(); | |
| 6705 | + // LiteSpeed: drop-in serves hits (visible + counted) unless the user | |
| 6706 | + // explicitly opted into the static fast path — see docblock. | |
| 6707 | + if ( Server::LITESPEED === Server::type() | |
| 6708 | + && ! self::litespeed_optin_enabled( $opts['litespeed_static_rewrite'] ?? false ) ) { | |
| 6653 | 6709 | return false; |
| 6654 | 6710 | } |
| 6655 | 6711 | // Apache without mod_headers is in EXACTLY the position LiteSpeed |
| 6656 | 6712 | // is in above: it can run the RewriteRule and serve the static |
| @@ -6664,10 +6720,8 @@ | ||
| 6664 | 6720 | // pinned at 0% on a working Apache cache.) |
| 6665 | 6721 | if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) { |
| 6666 | 6722 | return false; |
| 6667 | 6723 | } |
| 6668 | - // Stored read — reached from boot(); see stored_cache_opts(). | |
| 6669 | - $opts = self::stored_cache_opts(); | |
| 6670 | 6724 | return empty( $opts['mobile_separate'] ); |
| 6671 | 6725 | } |
| 6672 | 6726 | |
| 6673 | 6727 | /** |
| @@ -6785,8 +6839,10 @@ | ||
| 6785 | 6839 | case 'mobile_separate': |
| 6786 | 6840 | return 'Separate Mobile Cache is on, which disables the device-blind static rewrite. Cache hits are served by PHP instead. If your site serves the same HTML to every device, turn it off in Cache settings for much faster hits.'; |
| 6787 | 6841 | case 'no_mod_headers': |
| 6788 | 6842 | return "Apache's mod_headers is not loaded, so the static rewrite cannot mark its responses as cache hits. Enable mod_headers, or leave hits on the PHP path."; |
| 6843 | + case 'litespeed_dropin': | |
| 6844 | + return 'On LiteSpeed, cache hits are served by the PHP drop-in so every hit is tagged X-XSpeed-Cache and counted in the hit ratio — LiteSpeed\'s .htaccess engine cannot do either for statically served files. If raw TTFB matters more to you than hit accounting, turn on LiteSpeed Static Fast Path in Cache settings to serve hits straight from the web server.'; | |
| 6789 | 6845 | case 'skipped_nonce': |
| 6790 | 6846 | return 'The server config is correct, but pages are not reaching the static cache because they contain nonces, so hits are served by PHP instead. A static file is served with no PHP, so a nonce baked into one could never be refreshed and every anonymous form on the page would break once it expired — keeping these pages on PHP is deliberate. Nonces usually come from plugin widgets; disabling the ones the site does not use lets its pages be served statically again.'; |
| 6791 | 6847 | default: |
| 6792 | 6848 | return sprintf( 'The static rewrite is disabled (%s).', $code ); |
| @@ -6805,9 +6861,25 @@ | ||
| 6805 | 6861 | if ( empty( $opts['cache_enabled'] ) ) { |
| 6806 | 6862 | return ''; |
| 6807 | 6863 | } |
| 6808 | 6864 | if ( Server::LITESPEED === Server::type() ) { |
| 6809 | - return ''; // Intended on LiteSpeed — not a "block". | |
| 6865 | + // The opt-in is read RAW (stored_cache_opts), not through | |
| 6866 | + // Settings_Manager::get(): the schema's bool coercion is a PHP | |
| 6867 | + // cast, and (bool) "false" is true — so a junk string stored on | |
| 6868 | + // another server (where the key bypasses the schema) would come | |
| 6869 | + // back from the coercion layer as an ENABLE. Raw + the strict | |
| 6870 | + // parse below is the same read static_rewrite_allowed() makes, | |
| 6871 | + // so the two can't disagree either. (QA on #513) | |
| 6872 | + $stored = self::stored_cache_opts(); | |
| 6873 | + // The intended default — but no longer silent: with the opt-in | |
| 6874 | + // off, Health must be able to explain the PHP path and point at | |
| 6875 | + // the toggle instead of falling through to "reinstall the block" | |
| 6876 | + // advice that cannot work here. (#509) | |
| 6877 | + if ( ! self::litespeed_optin_enabled( $stored['litespeed_static_rewrite'] ?? false ) ) { | |
| 6878 | + return 'litespeed_dropin'; | |
| 6879 | + } | |
| 6880 | + $cache_opts = Settings_Manager::get( 'cache' ); | |
| 6881 | + return ! empty( $cache_opts['mobile_separate'] ) ? 'mobile_separate' : ''; | |
| 6810 | 6882 | } |
| 6811 | 6883 | if ( Server::APACHE === Server::type() && ! Server::apache_has_mod_headers() ) { |
| 6812 | 6884 | return 'no_mod_headers'; |
| 6813 | 6885 | } |
| @@ -7675,9 +7747,12 @@ | ||
| 7675 | 7747 | |
| 7676 | 7748 | $home = home_url( '/' ); |
| 7677 | 7749 | $host = (string) wp_parse_url( $home, PHP_URL_HOST ); |
| 7678 | 7750 | if ( '' === $host ) { |
| 7679 | - $result = array( 'active' => false, 'reason' => 'home_url has no host' ); | |
| 7751 | + // Environmental failure, not evidence the server config is wrong — | |
| 7752 | + // mark it inconclusive so Health surfaces say "could not verify" | |
| 7753 | + // instead of demanding a snippet paste. (#480) | |
| 7754 | + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'home_url has no host' ); | |
| 7680 | 7755 | set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); |
| 7681 | 7756 | return $result; |
| 7682 | 7757 | } |
| 7683 | 7758 | |
| @@ -7694,9 +7769,12 @@ | ||
| 7694 | 7769 | if ( ! file_exists( $probe_dir ) ) { |
| 7695 | 7770 | wp_mkdir_p( $probe_dir ); |
| 7696 | 7771 | } |
| 7697 | 7772 | if ( ! is_dir( $probe_dir ) ) { |
| 7698 | - $result = array( 'active' => false, 'reason' => 'cannot create probe dir' ); | |
| 7773 | + // A cache-dir permissions problem — the probe never ran, so this | |
| 7774 | + // says nothing about the nginx config. Inconclusive, not | |
| 7775 | + // "required". (#480) | |
| 7776 | + $result = array( 'active' => false, 'inconclusive' => true, 'reason' => 'cannot create probe dir' ); | |
| 7699 | 7777 | set_transient( 'xspeed_rewrite_probe', $result, MINUTE_IN_SECONDS ); |
| 7700 | 7778 | return $result; |
| 7701 | 7779 | } |
| 7702 | 7780 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents -- WP_Filesystem requires admin credentials we may not have here; the file is in our own cache dir. |
| @@ -8814,11 +8892,34 @@ | ||
| 8814 | 8892 | // Only honour known types; anything else falls back to a full purge. |
| 8815 | 8893 | if ( ! array_key_exists( $type, self::purge_types() ) ) { |
| 8816 | 8894 | $type = 'all'; |
| 8817 | 8895 | } |
| 8896 | + | |
| 8897 | + // Answer the browser BEFORE purging. "Purge All" fans out to the local | |
| 8898 | + // sweep, the object cache, CSS/edge listeners (outbound HTTP) and | |
| 8899 | + // third-party render caches, all in this one request — on a large site | |
| 8900 | + // that can outlive PHP-FPM's request_terminate_timeout, FPM kills the | |
| 8901 | + // worker mid-purge, and nginx answers the admin's click with a 502. | |
| 8902 | + // fastcgi_finish_request() exists on exactly those FPM setups: send | |
| 8903 | + // the redirect, close the connection, then keep purging in the same | |
| 8904 | + // process. Elsewhere (mod_php, CLI tests) fall back to purge-then- | |
| 8905 | + // redirect as before. | |
| 8906 | + $redirect = self::safe_purge_redirect( wp_get_referer() ); | |
| 8907 | + if ( function_exists( 'ignore_user_abort' ) ) { | |
| 8908 | + ignore_user_abort( true ); | |
| 8909 | + } | |
| 8910 | + if ( function_exists( 'set_time_limit' ) ) { | |
| 8911 | + @set_time_limit( 300 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort under safe-mode-like restrictions. | |
| 8912 | + } | |
| 8913 | + if ( function_exists( 'fastcgi_finish_request' ) ) { | |
| 8914 | + wp_safe_redirect( $redirect ); | |
| 8915 | + fastcgi_finish_request(); | |
| 8916 | + self::purge_type( $type ); | |
| 8917 | + exit; | |
| 8918 | + } | |
| 8919 | + | |
| 8818 | 8920 | self::purge_type( $type ); |
| 8819 | - | |
| 8820 | - wp_safe_redirect( self::safe_purge_redirect( wp_get_referer() ) ); | |
| 8921 | + wp_safe_redirect( $redirect ); | |
| 8821 | 8922 | exit; |
| 8822 | 8923 | } |
| 8823 | 8924 | |
| 8824 | 8925 | /** |