| @@ -72,13 +72,20 @@ | ||
| 72 | 72 | * |
| 73 | 73 | * Deliberately long. These files are only rewritten when the source |
| 74 | 74 | * asset's mtime changes, so a live, still-referenced asset keeps its |
| 75 | 75 | * original mtime forever — a short max-age here would delete assets the |
| 76 | - * current pages still link to and re-minify them on the next request, | |
| 77 | - * every day. 30 days means a superseded file is collected a month after | |
| 78 | - * the update that orphaned it, and a live one is regenerated (once) a | |
| 79 | - * month after it was built. | |
| 76 | + * current pages still link to. 30 days means a superseded file is | |
| 77 | + * collected roughly a month after the update that orphaned it. | |
| 80 | 78 | * |
| 79 | + * Age ALONE is not a liveness test, and this docblock used to claim it | |
| 80 | + * was safe because "a live one is regenerated (once) a month after it was | |
| 81 | + * built". That is wrong: regeneration only happens on a cache MISS, when | |
| 82 | + * PHP runs the enqueue pipeline. On a HIT PHP never boots, so nothing | |
| 83 | + * regenerates and the page keeps serving a dead link — the mitigation | |
| 84 | + * failed precisely on the well-cached sites it was meant to protect. | |
| 85 | + * `is_referenced()` is the actual guard; this max-age only decides when | |
| 86 | + * an UNREFERENCED file is collected. (#190) | |
| 87 | + * | |
| 81 | 88 | * A filter returning <= 0 disables the min/ phase rather than deleting |
| 82 | 89 | * everything — "no max age" is the safer reading of an unset value. |
| 83 | 90 | */ |
| 84 | 91 | public static function asset_max_age(): int { |
| @@ -115,12 +122,19 @@ | ||
| 115 | 122 | $budget = self::budget(); |
| 116 | 123 | $cursor = self::read_cursor(); |
| 117 | 124 | $removed = 0; |
| 118 | 125 | |
| 126 | + // Rebuild the "which assets are still linked" index per run. Memoized | |
| 127 | + // within a run (a sweep examines many files), but never across runs — | |
| 128 | + // pages are written and purged between ticks, and a stale index would | |
| 129 | + // either protect an orphan forever or, worse, fail to protect a live | |
| 130 | + // asset. (#190) | |
| 131 | + self::reset_reference_index(); | |
| 132 | + | |
| 119 | 133 | // Resolve the global TTL once — Settings_Manager::get() is cheap but |
| 120 | 134 | // this runs per candidate otherwise. |
| 121 | 135 | $opts = Settings_Manager::get( 'cache' ); |
| 122 | - $default_ttl = max( 1, (int) ( $opts['cache_expiry'] ?? 24 ) ) * HOUR_IN_SECONDS; | |
| 136 | + $default_ttl = max( 1, (int) ( $opts['cache_expiry'] ?? \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXPIRY_HOURS ) ) * HOUR_IN_SECONDS; | |
| 123 | 137 | $asset_ttl = self::asset_max_age(); |
| 124 | 138 | $now = time(); |
| 125 | 139 | |
| 126 | 140 | // Start at the phase we paused in and carry on round the list. Each |
| @@ -185,11 +199,17 @@ | ||
| 185 | 199 | } |
| 186 | 200 | |
| 187 | 201 | $removed = 0; |
| 188 | 202 | |
| 189 | - // The flat cache is one flat directory; min/ and rest/ sit inside it | |
| 190 | - // with their own rules, so don't descend for that phase. | |
| 191 | - foreach ( self::files( $root, 'flat' !== $phase ) as $path ) { | |
| 203 | + // Every phase descends now. The flat phase used to walk only the top | |
| 204 | + // level, back when entries lived directly in XSPEED_CACHE_DIR — but | |
| 205 | + // per-site buckets moved every entry one level down (or two, for a | |
| 206 | + // subdirectory-multisite subsite), so a non-recursive walk stopped | |
| 207 | + // seeing the only layout that exists and GC silently expired nothing. | |
| 208 | + // On single sites too: their entries are bucketed under the host as | |
| 209 | + // well. is_candidate() is what keeps min/ and rest/ out, so recursing | |
| 210 | + // here does not pull them in. (QA B1 on #166) | |
| 211 | + foreach ( self::files( $root, true ) as $path ) { | |
| 192 | 212 | // Cheap name test first: a non-candidate costs no stat and no |
| 193 | 213 | // budget. Everything else in these directories (index.php, |
| 194 | 214 | // .meta, .br, .mobile-separate, the hits log) is either a |
| 195 | 215 | // sibling collected with its parent or must never be touched. |
| @@ -209,12 +229,23 @@ | ||
| 209 | 229 | --$budget; |
| 210 | 230 | $after = $path; |
| 211 | 231 | |
| 212 | 232 | $max_age = 'min' === $phase ? $asset_ttl : self::page_max_age( $phase, $path, $default_ttl ); |
| 213 | - if ( self::is_stale( $path, $now, $max_age ) ) { | |
| 214 | - self::delete_entry( $path ); | |
| 215 | - ++$removed; | |
| 233 | + if ( ! self::is_stale( $path, $now, $max_age ) ) { | |
| 234 | + continue; | |
| 216 | 235 | } |
| 236 | + | |
| 237 | + // An asset a live cached page still links to is NOT collectable, | |
| 238 | + // however old it is. Age is a hint about orphanhood; this is the | |
| 239 | + // fact. Without it GC deleted files every cached page pointed at | |
| 240 | + // and left the pages in place, so the site served 200s full of | |
| 241 | + // 404s. (#190) | |
| 242 | + if ( 'min' === $phase && self::is_referenced( $path ) ) { | |
| 243 | + continue; | |
| 244 | + } | |
| 245 | + | |
| 246 | + self::delete_entry( $path ); | |
| 247 | + ++$removed; | |
| 217 | 248 | } |
| 218 | 249 | |
| 219 | 250 | return array( $removed, '' ); |
| 220 | 251 | } |
| @@ -241,10 +272,42 @@ | ||
| 241 | 272 | private static function is_candidate( string $phase, string $path ): bool { |
| 242 | 273 | $name = basename( $path ); |
| 243 | 274 | switch ( $phase ) { |
| 244 | 275 | case 'flat': |
| 245 | - return '.html' === substr( $name, -5 ) | |
| 246 | - && dirname( $path ) === XSPEED_CACHE_DIR; | |
| 276 | + /* | |
| 277 | + * Flat entries live in a per-site bucket since #6: | |
| 278 | + * | |
| 279 | + * <cache>/<host>/<md5>.html single site, main blog | |
| 280 | + * <cache>/<host>/<prefix>/<md5>.html subdirectory subsite | |
| 281 | + * | |
| 282 | + * Both depths must be accepted — the two-level form is where a | |
| 283 | + * subdirectory-multisite subsite's pages live, and accepting | |
| 284 | + * only one level left them uncollectable. The legacy top-level | |
| 285 | + * layout stays accepted so entries written before #6 still age | |
| 286 | + * out instead of lingering forever. (QA B1 on #166) | |
| 287 | + * | |
| 288 | + * Depth alone is not the guard against min/ and rest/: those | |
| 289 | + * are excluded by name, at either level, because the sweep now | |
| 290 | + * recurses and would otherwise treat their contents as pages. | |
| 291 | + */ | |
| 292 | + if ( '.html' !== substr( $name, -5 ) ) { | |
| 293 | + return false; | |
| 294 | + } | |
| 295 | + $parent = dirname( $path ); | |
| 296 | + $depth1 = $parent === XSPEED_CACHE_DIR; | |
| 297 | + $depth2 = dirname( $parent ) === XSPEED_CACHE_DIR; | |
| 298 | + $depth3 = dirname( dirname( $parent ) ) === XSPEED_CACHE_DIR; | |
| 299 | + if ( ! $depth1 && ! $depth2 && ! $depth3 ) { | |
| 300 | + return false; | |
| 301 | + } | |
| 302 | + // Walk up to the cache root looking for a reserved directory, | |
| 303 | + // so `min/` and `rest/` are excluded however deep we are. | |
| 304 | + for ( $dir = $parent; strlen( $dir ) > strlen( XSPEED_CACHE_DIR ); $dir = dirname( $dir ) ) { | |
| 305 | + if ( in_array( basename( $dir ), array( 'min', 'rest' ), true ) ) { | |
| 306 | + return false; | |
| 307 | + } | |
| 308 | + } | |
| 309 | + return true; | |
| 247 | 310 | case 'static': |
| 248 | 311 | return 'index.html' === $name; |
| 249 | 312 | case 'min': |
| 250 | 313 | return '.css' === substr( $name, -4 ) || '.js' === substr( $name, -3 ); |
| @@ -270,13 +333,46 @@ | ||
| 270 | 333 | * The static tree never has a .meta: store_static() only runs for plain |
| 271 | 334 | * 200 text/html, so the global TTL is always correct there. |
| 272 | 335 | */ |
| 273 | 336 | private static function page_max_age( string $phase, string $path, int $default_ttl ): int { |
| 337 | + if ( 'static' === $phase ) { | |
| 338 | + // A nonce-bearing page records its own deadline when written: the | |
| 339 | + // nonce dies on WordPress's schedule, not the site's cache | |
| 340 | + // lifetime, and this tree is served without PHP so nothing else | |
| 341 | + // can enforce it. A site caching for a week would otherwise hand | |
| 342 | + // out a dead nonce for six and a half days of it, breaking every | |
| 343 | + // anonymous form on the page. | |
| 344 | + $expires_file = dirname( $path ) . '/.xspeed-expires'; | |
| 345 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- sidecar read on a cron sweep; WP_Filesystem is not loaded here. | |
| 346 | + $expires = is_readable( $expires_file ) ? (int) @file_get_contents( $expires_file ) : 0; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- file may vanish between the check and the read. | |
| 347 | + if ( $expires > 0 ) { | |
| 348 | + $mtime = @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- false is handled below. | |
| 349 | + // Express the deadline as an age, since the caller compares | |
| 350 | + // against the file's own mtime. A file already past its | |
| 351 | + // deadline gets 0, which expires it on this sweep. | |
| 352 | + return ( false !== $mtime ) ? max( 0, $expires - $mtime ) : 0; | |
| 353 | + } | |
| 354 | + | |
| 355 | + return $default_ttl; | |
| 356 | + } | |
| 274 | 357 | if ( 'flat' !== $phase ) { |
| 275 | 358 | return $default_ttl; |
| 276 | 359 | } |
| 277 | - $meta = Cache::read_meta( basename( $path, '.html' ) ); | |
| 278 | - $ttl = isset( $meta['ttl'] ) ? (int) $meta['ttl'] : 0; | |
| 360 | + // Read the sidecar NEXT TO THE FILE. Cache::read_meta() rebuilds the | |
| 361 | + // path from the key via cache_meta_for(), which resolves against the | |
| 362 | + // CURRENT request's site bucket — wrong for a cron sweep walking | |
| 363 | + // every site's entries, and wrong for the legacy top-level layout. | |
| 364 | + // The sidecar is always `<file>.meta`, so derive it directly. (#6) | |
| 365 | + $meta_file = substr( $path, 0, -5 ) . '.meta'; | |
| 366 | + $ttl = 0; | |
| 367 | + if ( is_file( $meta_file ) ) { | |
| 368 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache sidecar; WP_Filesystem needs admin credentials unavailable during cron. | |
| 369 | + $raw = (string) @file_get_contents( $meta_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable sidecar just means "use the global TTL". | |
| 370 | + $decoded = json_decode( $raw, true ); | |
| 371 | + if ( is_array( $decoded ) && isset( $decoded['ttl'] ) ) { | |
| 372 | + $ttl = (int) $decoded['ttl']; | |
| 373 | + } | |
| 374 | + } | |
| 279 | 375 | return $ttl > 0 ? $ttl : $default_ttl; |
| 280 | 376 | } |
| 281 | 377 | |
| 282 | 378 | /** |
| @@ -297,8 +393,84 @@ | ||
| 297 | 393 | return ( $now - (int) $mtime ) > $max_age; |
| 298 | 394 | } |
| 299 | 395 | |
| 300 | 396 | /** |
| 397 | + * Asset paths (relative to `min/`) that some cached page still links to. | |
| 398 | + * | |
| 399 | + * Built once per run and memoized: a sweep examines up to `budget()` | |
| 400 | + * files, and re-reading every cached page for each of them would turn a | |
| 401 | + * cheap cron tick into an O(assets x pages) crawl. | |
| 402 | + * | |
| 403 | + * Scans BOTH cache trees. The static tree is served by nginx without ever | |
| 404 | + * running PHP, so a page there can outlive any invalidation we do in PHP — | |
| 405 | + * missing it would leave exactly the 404s this fix exists to prevent, on | |
| 406 | + * the fastest path. | |
| 407 | + * | |
| 408 | + * @var array<string,true>|null | |
| 409 | + */ | |
| 410 | + private static $referenced = null; | |
| 411 | + | |
| 412 | + /** Forget the memo — the next run rebuilds it. */ | |
| 413 | + public static function reset_reference_index(): void { | |
| 414 | + self::$referenced = null; | |
| 415 | + } | |
| 416 | + | |
| 417 | + /** | |
| 418 | + * Is this asset linked from any cached page? | |
| 419 | + * | |
| 420 | + * @param string $path Absolute path to a file under `min/`. | |
| 421 | + */ | |
| 422 | + private static function is_referenced( string $path ): bool { | |
| 423 | + if ( null === self::$referenced ) { | |
| 424 | + self::$referenced = self::build_reference_index(); | |
| 425 | + } | |
| 426 | + | |
| 427 | + $min_root = self::phase_root( 'min' ); | |
| 428 | + if ( null === $min_root ) { | |
| 429 | + return false; | |
| 430 | + } | |
| 431 | + // Compare on the path RELATIVE to min/, which is what a page's URL | |
| 432 | + // carries — absolute paths differ between the cache dir and the URL. | |
| 433 | + $rel = ltrim( str_replace( $min_root, '', $path ), '/' ); | |
| 434 | + | |
| 435 | + return isset( self::$referenced[ $rel ] ); | |
| 436 | + } | |
| 437 | + | |
| 438 | + /** | |
| 439 | + * Read every cached page once and collect the assets they reference. | |
| 440 | + * | |
| 441 | + * @return array<string,true> Keys are paths relative to `min/`. | |
| 442 | + */ | |
| 443 | + private static function build_reference_index(): array { | |
| 444 | + $found = array(); | |
| 445 | + | |
| 446 | + foreach ( array( 'flat', 'static' ) as $phase ) { | |
| 447 | + $root = self::phase_root( $phase ); | |
| 448 | + if ( null === $root || ! is_dir( $root ) ) { | |
| 449 | + continue; | |
| 450 | + } | |
| 451 | + foreach ( self::files( $root, 'flat' !== $phase ) as $file ) { | |
| 452 | + if ( '.html' !== substr( $file, -5 ) ) { | |
| 453 | + continue; | |
| 454 | + } | |
| 455 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading our own cache file; WP_Filesystem is unavailable in cron context. | |
| 456 | + $html = (string) @file_get_contents( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a concurrent purge can unlink mid-walk; '' is handled. | |
| 457 | + if ( '' === $html ) { | |
| 458 | + continue; | |
| 459 | + } | |
| 460 | + if ( ! preg_match_all( '#/cache/xspeed/min/([^"\'\s?>]+\.(?:css|js))#', $html, $m ) ) { | |
| 461 | + continue; | |
| 462 | + } | |
| 463 | + foreach ( $m[1] as $rel ) { | |
| 464 | + $found[ $rel ] = true; | |
| 465 | + } | |
| 466 | + } | |
| 467 | + } | |
| 468 | + | |
| 469 | + return $found; | |
| 470 | + } | |
| 471 | + | |
| 472 | + /** | |
| 301 | 473 | * Delete a cache entry and every sibling that only exists because of it, |
| 302 | 474 | * so the sweep never creates the orphans it is there to remove: |
| 303 | 475 | * |
| 304 | 476 | * <md5>.html → <md5>.meta, <md5>.html.br |
| @@ -316,8 +488,59 @@ | ||
| 316 | 488 | if ( '.html' === substr( $path, -5 ) ) { |
| 317 | 489 | $meta = substr( $path, 0, -5 ) . '.meta'; |
| 318 | 490 | if ( file_exists( $meta ) ) { |
| 319 | 491 | wp_delete_file( $meta ); |
| 492 | + } | |
| 493 | + } | |
| 494 | + | |
| 495 | + // Deleting an asset and invalidating the pages that embed it are ONE | |
| 496 | + // operation, so the two caches can never disagree. is_referenced() | |
| 497 | + // already keeps a linked asset alive, so this is the belt to that | |
| 498 | + // braces: it covers the races the index cannot see — a page written | |
| 499 | + // after the index was built, or a reference in a form the scan did | |
| 500 | + // not match. Without it, any gap between the two caches shows up as a | |
| 501 | + // 200 page full of 404s. (#190 AC2) | |
| 502 | + $min_root = self::phase_root( 'min' ); | |
| 503 | + if ( null !== $min_root && 0 === strpos( $path, $min_root . '/' ) ) { | |
| 504 | + self::purge_pages_referencing( ltrim( str_replace( $min_root, '', $path ), '/' ) ); | |
| 505 | + } | |
| 506 | + } | |
| 507 | + | |
| 508 | + /** | |
| 509 | + * Remove every cached page that links to the given asset. | |
| 510 | + * | |
| 511 | + * Walks both trees: the static one is served by nginx without PHP, so a | |
| 512 | + * page left there keeps serving the dead link no matter what the flat | |
| 513 | + * cache says. | |
| 514 | + * | |
| 515 | + * @param string $rel Asset path relative to `min/`. | |
| 516 | + */ | |
| 517 | + private static function purge_pages_referencing( string $rel ): void { | |
| 518 | + if ( '' === $rel ) { | |
| 519 | + return; | |
| 520 | + } | |
| 521 | + | |
| 522 | + foreach ( array( 'flat', 'static' ) as $phase ) { | |
| 523 | + $root = self::phase_root( $phase ); | |
| 524 | + if ( null === $root || ! is_dir( $root ) ) { | |
| 525 | + continue; | |
| 526 | + } | |
| 527 | + foreach ( self::files( $root, 'flat' !== $phase ) as $file ) { | |
| 528 | + if ( '.html' !== substr( $file, -5 ) ) { | |
| 529 | + continue; | |
| 530 | + } | |
| 531 | + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading our own cache file; WP_Filesystem is unavailable in cron context. | |
| 532 | + $html = (string) @file_get_contents( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- concurrent purge can unlink mid-walk. | |
| 533 | + if ( '' === $html || false === strpos( $html, $rel ) ) { | |
| 534 | + continue; | |
| 535 | + } | |
| 536 | + | |
| 537 | + wp_delete_file( $file ); | |
| 538 | + foreach ( array( $file . '.br', substr( $file, 0, -5 ) . '.meta' ) as $sibling ) { | |
| 539 | + if ( file_exists( $sibling ) ) { | |
| 540 | + wp_delete_file( $sibling ); | |
| 541 | + } | |
| 542 | + } | |
| 320 | 543 | } |
| 321 | 544 | } |
| 322 | 545 | } |
| 323 | 546 | |