| @@ -257,9 +257,26 @@ | ||
| 257 | 257 | // alone cannot tell "added beside nothing" from "replaced live code". |
| 258 | 258 | // This filter fires only when the upgrader removed an existing copy, |
| 259 | 259 | // which is exactly the difference. Registered as a filter listener |
| 260 | 260 | // that returns its input untouched. (#303) |
| 261 | - add_filter( 'upgrader_clear_destination', array( __CLASS__, 'note_cleared_destination' ), 10, 1 ); | |
| 261 | + add_filter( 'upgrader_clear_destination', array( __CLASS__, 'note_cleared_destination' ), 10, 4 ); | |
| 262 | + // …but `upgrader_clear_destination` fires whenever the upgrader was | |
| 263 | + // ASKED to clear, not only when it removed something: | |
| 264 | + // WP_Upgrader::clear_destination() returns true early when the | |
| 265 | + // destination does not exist. Looked at before the delete, while the | |
| 266 | + // old copy is still on disk. (#303) | |
| 267 | + // | |
| 268 | + // PHP_INT_MAX, because the folder name is only final once every other | |
| 269 | + // listener has had its turn. Update libraries that normalise | |
| 270 | + // `plugin-1.2.3/` to `plugin/` (Plugin Update Checker, EDD Software | |
| 271 | + // Licensing, GitHub-sourced zips) rename the extracted directory on | |
| 272 | + // this same filter at priority 10 or later, and core derives the real | |
| 273 | + // destination from the FILTERED source. Measured at 10, a genuine | |
| 274 | + // replacement read as "nothing was there" and the stale cache stayed. | |
| 275 | + // note_cleared_destination() cross-checks the folder core actually | |
| 276 | + // cleared against the one measured here, for a renamer that runs | |
| 277 | + // later still. (#407 QA) | |
| 278 | + add_filter( 'upgrader_source_selection', array( __CLASS__, 'note_destination_state' ), PHP_INT_MAX, 4 ); | |
| 262 | 279 | // Unattended auto-updates are the case that matters most here: they |
| 263 | 280 | // land overnight with nobody around to purge by hand, which is the |
| 264 | 281 | // exact scenario the stale cache goes undiagnosed in. WordPress fires |
| 265 | 282 | // this INSTEAD of a per-item upgrader_process_complete for some |
| @@ -1209,9 +1226,19 @@ | ||
| 1209 | 1226 | $key = self::cache_key(); |
| 1210 | 1227 | $file = self::cache_file_for( $key ); |
| 1211 | 1228 | |
| 1212 | 1229 | if ( file_exists( $file ) && ! self::is_expired( $file ) ) { |
| 1213 | - Hit_Counter::record_hit(); | |
| 1230 | + // Symmetric with the miss branch below: a bot, scanner or one of | |
| 1231 | + // xSpeed's own warm/benchmark requests that lands a HIT must not | |
| 1232 | + // inflate the ratio either — excluding only their misses would | |
| 1233 | + // shrink the denominator while their hits kept feeding the | |
| 1234 | + // numerator, making the displayed ratio MORE optimistic than | |
| 1235 | + // before the exclusion existed. | |
| 1236 | + if ( self::miss_is_excluded() ) { | |
| 1237 | + Hit_Counter::record_excluded(); | |
| 1238 | + } else { | |
| 1239 | + Hit_Counter::record_hit(); | |
| 1240 | + } | |
| 1214 | 1241 | // Emit the HIT marker on THIS path too. The drop-in |
| 1215 | 1242 | // (advanced-cache.php) sends "HIT (php)" and the nginx static |
| 1216 | 1243 | // rewrite sends "HIT (nginx)", but this template_redirect |
| 1217 | 1244 | // serve path — the one that runs when the drop-in isn't loaded |
| @@ -1824,11 +1851,27 @@ | ||
| 1824 | 1851 | * `~utm_…` default vs `my_utm_source`. A param name that is genuinely |
| 1825 | 1852 | * unknown now bypasses the cache, which is the safe direction. |
| 1826 | 1853 | */ |
| 1827 | 1854 | private static function query_key_is_ignored( string $key, array $ignored ): bool { |
| 1855 | + if ( in_array( $key, self::NEVER_IGNORED_QUERY_PARAMS, true ) ) { | |
| 1856 | + return false; | |
| 1857 | + } | |
| 1828 | 1858 | return Glob_Matcher::any_match_name( $ignored, $key ); |
| 1829 | 1859 | } |
| 1830 | 1860 | |
| 1861 | + /** | |
| 1862 | + * Query params no ignored-params entry can match, glob or regex. | |
| 1863 | + * | |
| 1864 | + * A measuring request asks for the page as it is before optimisation | |
| 1865 | + * (`xspeed_css=off`), with a one-time value (`xspeed_nc`) so no cache | |
| 1866 | + * has a copy of it. The answer must be rendered for that request. A | |
| 1867 | + * list entry such as `xspeed_*` or `*` would make both params | |
| 1868 | + * decoration: the drop-in would serve the canonical, already-optimised | |
| 1869 | + * entry before any plugin loads, and the measurement would describe | |
| 1870 | + * the optimised page instead of the source. | |
| 1871 | + */ | |
| 1872 | + public const NEVER_IGNORED_QUERY_PARAMS = array( 'xspeed_css', 'xspeed_nc' ); | |
| 1873 | + | |
| 1831 | 1874 | public static function cache_key() { |
| 1832 | 1875 | $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : 'default'; |
| 1833 | 1876 | |
| 1834 | 1877 | // Cacheable 404s share ONE generic per-host entry — keying them by |
| @@ -4679,8 +4722,13 @@ | ||
| 4679 | 4722 | * more than it needs to. A cold cache is the cheap direction, and the |
| 4680 | 4723 | * alternative — scoping the signal per upgrader — is not knowable from |
| 4681 | 4724 | * `upgrader_clear_destination`. |
| 4682 | 4725 | * |
| 4726 | + * The observed-destination signal is dropped here too. The two are read | |
| 4727 | + * together and have to expire together: leaving "the directory was not | |
| 4728 | + * there" behind would let a first install answer for whatever ran next | |
| 4729 | + * in the same request, and that one's mistake is a cache left stale. | |
| 4730 | + * | |
| 4683 | 4731 | * @return void |
| 4684 | 4732 | */ |
| 4685 | 4733 | public static function forget_cleared_destination(): void { |
| 4686 | 4734 | if ( self::$upgrade_dispatch_depth > 0 ) { |
| @@ -4688,12 +4736,84 @@ | ||
| 4688 | 4736 | } |
| 4689 | 4737 | |
| 4690 | 4738 | if ( 0 === self::$upgrade_dispatch_depth ) { |
| 4691 | 4739 | self::$upgrade_cleared_destination = false; |
| 4740 | + self::$upgrade_destination_existed = null; | |
| 4741 | + self::$upgrade_destination_folder = ''; | |
| 4692 | 4742 | } |
| 4693 | 4743 | } |
| 4694 | 4744 | |
| 4695 | 4745 | /** |
| 4746 | + * Whether the destination this run installs into was already there. | |
| 4747 | + * | |
| 4748 | + * Null while unknown — an upgrader whose target we cannot work out keeps | |
| 4749 | + * the old behaviour rather than being guessed at. | |
| 4750 | + * | |
| 4751 | + * @var bool|null | |
| 4752 | + */ | |
| 4753 | + private static $upgrade_destination_existed = null; | |
| 4754 | + | |
| 4755 | + /** | |
| 4756 | + * The folder name that answer was measured against, so the destination | |
| 4757 | + * WordPress reports on `upgrader_clear_destination` can be checked | |
| 4758 | + * against it. Empty when nothing was measured. | |
| 4759 | + * | |
| 4760 | + * @var string | |
| 4761 | + */ | |
| 4762 | + private static $upgrade_destination_folder = ''; | |
| 4763 | + | |
| 4764 | + /** | |
| 4765 | + * Note whether the package's destination exists, before it is cleared. | |
| 4766 | + * | |
| 4767 | + * `upgrader_source_selection` is the last hook that fires while the old | |
| 4768 | + * copy is still on disk, and the extracted source folder name is the | |
| 4769 | + * directory the package will install into. A pass-through listener: the | |
| 4770 | + * source is returned untouched. | |
| 4771 | + * | |
| 4772 | + * @param mixed $source Extracted package directory. | |
| 4773 | + * @param mixed $remote_src Unused; the package's remote source. | |
| 4774 | + * @param mixed $upgrader The upgrader instance, if one was supplied. | |
| 4775 | + * @param mixed $hook_extra Context supplied by the upgrader. | |
| 4776 | + * @return mixed The source, unchanged. | |
| 4777 | + */ | |
| 4778 | + public static function note_destination_state( $source, $remote_src = '', $upgrader = null, $hook_extra = array() ) { | |
| 4779 | + self::$upgrade_destination_existed = null; | |
| 4780 | + self::$upgrade_destination_folder = ''; | |
| 4781 | + | |
| 4782 | + $root = self::upgrade_destination_root( $upgrader, is_array( $hook_extra ) ? $hook_extra : array() ); | |
| 4783 | + if ( null !== $root && is_string( $source ) && '' !== $source ) { | |
| 4784 | + $folder = basename( rtrim( $source, '/\\' ) ); | |
| 4785 | + if ( '' !== $folder ) { | |
| 4786 | + self::$upgrade_destination_existed = is_dir( rtrim( $root, '/\\' ) . '/' . $folder ); | |
| 4787 | + self::$upgrade_destination_folder = $folder; | |
| 4788 | + } | |
| 4789 | + } | |
| 4790 | + | |
| 4791 | + return $source; | |
| 4792 | + } | |
| 4793 | + | |
| 4794 | + /** | |
| 4795 | + * Where a package of this kind installs to, or null if we cannot tell. | |
| 4796 | + * | |
| 4797 | + * @param mixed $upgrader The upgrader instance, if one was supplied. | |
| 4798 | + * @param array $hook_extra Context supplied by the upgrader. | |
| 4799 | + * @return string|null | |
| 4800 | + */ | |
| 4801 | + private static function upgrade_destination_root( $upgrader, array $hook_extra ): ?string { | |
| 4802 | + $type = isset( $hook_extra['type'] ) ? (string) $hook_extra['type'] : ''; | |
| 4803 | + | |
| 4804 | + if ( 'plugin' === $type || $upgrader instanceof \Plugin_Upgrader ) { | |
| 4805 | + return defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : null; | |
| 4806 | + } | |
| 4807 | + | |
| 4808 | + if ( 'theme' === $type || $upgrader instanceof \Theme_Upgrader ) { | |
| 4809 | + return function_exists( 'get_theme_root' ) ? (string) get_theme_root() : null; | |
| 4810 | + } | |
| 4811 | + | |
| 4812 | + return null; | |
| 4813 | + } | |
| 4814 | + | |
| 4815 | + /** | |
| 4696 | 4816 | * Record that the upgrader cleared an existing destination. |
| 4697 | 4817 | * |
| 4698 | 4818 | * A pass-through listener on `upgrader_clear_destination`: WordPress only |
| 4699 | 4819 | * fires it when `clear_destination` was set AND something was there to |
| @@ -4702,13 +4822,28 @@ | ||
| 4702 | 4822 | * |
| 4703 | 4823 | * @param true|\WP_Error $removed Whether the destination was cleared. |
| 4704 | 4824 | * @return true|\WP_Error |
| 4705 | 4825 | */ |
| 4706 | - public static function note_cleared_destination( $removed ) { | |
| 4826 | + public static function note_cleared_destination( $removed, $local_destination = '', $remote_destination = '', $hook_extra = array() ) { | |
| 4707 | 4827 | if ( ! is_wp_error( $removed ) ) { |
| 4708 | 4828 | self::$upgrade_cleared_destination = true; |
| 4709 | 4829 | } |
| 4710 | 4830 | |
| 4831 | + // $remote_destination is the directory WordPress actually cleared, | |
| 4832 | + // derived from the source AFTER every `upgrader_source_selection` | |
| 4833 | + // listener ran. If its folder is not the one note_destination_state() | |
| 4834 | + // measured, a listener that ran after ours renamed the package, and | |
| 4835 | + // the "was it there?" answer is about a directory that was never going | |
| 4836 | + // to be written. Unknown is the answer that purges, so that is what it | |
| 4837 | + // becomes. Only the last segment is compared: over FTP the remote | |
| 4838 | + // path sits under the server's own root, not WP_PLUGIN_DIR. (#407 QA) | |
| 4839 | + if ( is_string( $remote_destination ) && '' !== $remote_destination ) { | |
| 4840 | + $cleared_folder = basename( rtrim( $remote_destination, '/\\' ) ); | |
| 4841 | + if ( '' !== $cleared_folder && $cleared_folder !== self::$upgrade_destination_folder ) { | |
| 4842 | + self::$upgrade_destination_existed = null; | |
| 4843 | + } | |
| 4844 | + } | |
| 4845 | + | |
| 4711 | 4846 | return $removed; |
| 4712 | 4847 | } |
| 4713 | 4848 | |
| 4714 | 4849 | /** |
| @@ -4822,8 +4957,18 @@ | ||
| 4822 | 4957 | if ( 'install' === $action && ! $cleared ) { |
| 4823 | 4958 | return false; |
| 4824 | 4959 | } |
| 4825 | 4960 | |
| 4961 | + // A cleared destination is only evidence of a replacement if there was | |
| 4962 | + // something in it. Core returns success from clear_destination() for a | |
| 4963 | + // destination that never existed, so a first-time install arrived here | |
| 4964 | + // looking exactly like an upload-and-replace and bought a cold cache | |
| 4965 | + // for a plugin that is not even active yet. Only acted on when we | |
| 4966 | + // positively know the directory was absent. | |
| 4967 | + if ( 'install' === $action && false === self::$upgrade_destination_existed ) { | |
| 4968 | + return false; | |
| 4969 | + } | |
| 4970 | + | |
| 4826 | 4971 | // 'translation' is the one update type that cannot change rendered |
| 4827 | 4972 | // markup. Anything else — including an empty type from a custom |
| 4828 | 4973 | // updater — is treated as cache-invalidating, because guessing wrong |
| 4829 | 4974 | // in that direction only costs a cold cache. |
| @@ -5648,8 +5793,13 @@ | ||
| 5648 | 5793 | private static function miss_is_excluded(): bool { |
| 5649 | 5794 | if ( function_exists( 'is_404' ) && is_404() ) { |
| 5650 | 5795 | return true; |
| 5651 | 5796 | } |
| 5797 | + // A marked request is ours whatever its UA says: a renamed warmer, | |
| 5798 | + // or a probe that has to send a browser's UA. | |
| 5799 | + if ( Self_Traffic::request_is_marked() ) { | |
| 5800 | + return true; | |
| 5801 | + } | |
| 5652 | 5802 | $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) |
| 5653 | 5803 | ? sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_USER_AGENT'] ) ) |
| 5654 | 5804 | : ''; |
| 5655 | 5805 | return Hit_Counter::is_bot_ua( $ua ); |
| @@ -6420,9 +6570,9 @@ | ||
| 6420 | 6570 | */ |
| 6421 | 6571 | $opts = self::stored_cache_opts(); |
| 6422 | 6572 | $ignored = is_array( $opts['ignored_query_params'] ?? null ) |
| 6423 | 6573 | ? $opts['ignored_query_params'] |
| 6424 | - : \XSpeed\Modules\Cache\CacheModule::DEFAULT_IGNORED_QUERY_PARAMS; | |
| 6574 | + : \XSpeed\Modules\Cache\CacheModule::default_ignored_query_params(); | |
| 6425 | 6575 | |
| 6426 | 6576 | $parts = array(); |
| 6427 | 6577 | foreach ( $ignored as $pattern ) { |
| 6428 | 6578 | $pattern = trim( (string) $pattern ); |
| @@ -6456,9 +6606,12 @@ | ||
| 6456 | 6606 | if ( ! is_dir( XSPEED_CACHE_DIR ) && ! wp_mkdir_p( XSPEED_CACHE_DIR ) ) { |
| 6457 | 6607 | return; |
| 6458 | 6608 | } |
| 6459 | 6609 | |
| 6460 | - $payload = '(?:' . implode( '|', array_unique( $parts ) ) . ')'; | |
| 6610 | + // The drop-in anchors this as `^…$`, so the lookahead refuses the | |
| 6611 | + // never-ignored names whole, whatever entry would have matched them. | |
| 6612 | + $never = implode( '|', array_map( static fn ( $p ) => preg_quote( $p, '#' ), self::NEVER_IGNORED_QUERY_PARAMS ) ); | |
| 6613 | + $payload = '(?!(?:' . $never . ')$)(?:' . implode( '|', array_unique( $parts ) ) . ')'; | |
| 6461 | 6614 | |
| 6462 | 6615 | // Only write when the value actually changed. This runs from |
| 6463 | 6616 | // reconcile_mobile_separate() on CacheModule::boot(), so an |
| 6464 | 6617 | // unconditional write cost a file write and an exclusive lock on every |
| @@ -6972,9 +7125,11 @@ | ||
| 6972 | 7125 | 'redirection' => 2, |
| 6973 | 7126 | // Bust any per-device cache so we compare freshly-rendered |
| 6974 | 7127 | // HTML, and pass the device UA the site would branch on. |
| 6975 | 7128 | 'user-agent' => $ua, |
| 6976 | - 'headers' => array( 'Cache-Control' => 'no-cache' ), | |
| 7129 | + // A real device UA by design, so only the header marks | |
| 7130 | + // this as ours to analytics and the hit ratio. | |
| 7131 | + 'headers' => Self_Traffic::headers( array( 'Cache-Control' => 'no-cache' ) ), | |
| 6977 | 7132 | ) |
| 6978 | 7133 | ); |
| 6979 | 7134 | if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) { |
| 6980 | 7135 | return null; |
| @@ -7794,9 +7949,9 @@ | ||
| 7794 | 7949 | // don't repeat the wait every minute. |
| 7795 | 7950 | 'timeout' => 3, |
| 7796 | 7951 | 'sslverify' => ! $is_local, |
| 7797 | 7952 | 'redirection' => 0, |
| 7798 | - 'headers' => array( 'Cache-Control' => 'no-cache' ), | |
| 7953 | + 'headers' => Self_Traffic::headers( array( 'Cache-Control' => 'no-cache' ) ), | |
| 7799 | 7954 | ) |
| 7800 | 7955 | ); |
| 7801 | 7956 | |
| 7802 | 7957 | // Best-effort cleanup so we don't accumulate probe dirs even |
| @@ -8516,8 +8671,16 @@ | ||
| 8516 | 8671 | ); |
| 8517 | 8672 | $source_contents = str_replace( |
| 8518 | 8673 | '@@XSPEED_UA_RE@@', |
| 8519 | 8674 | str_replace( "'", "\\'", $ua_rule['regex'] ), |
| 8675 | + $source_contents | |
| 8676 | + ); | |
| 8677 | + // Which user agents must not count toward the hit ratio. Built here | |
| 8678 | + // because `xspeed_self_user_agents` is a filter the drop-in cannot | |
| 8679 | + // call. A renamed warmer is caught by Self_Traffic::HEADER instead. | |
| 8680 | + $source_contents = str_replace( | |
| 8681 | + '@@XSPEED_HIT_EXCLUDE_RE@@', | |
| 8682 | + str_replace( "'", "\\'", Hit_Counter::excluded_ua_regex() ), | |
| 8520 | 8683 | $source_contents |
| 8521 | 8684 | ); |
| 8522 | 8685 | |
| 8523 | 8686 | /* |