PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
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 1.1.2 All 35 releases
xspeed / includes / class-cache-gc.php

class-cache-gc.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.0, at includes/class-cache-gc.php

711 lines 26.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cache garbage collection.
4 *
5 * Invalidation everywhere else in the plugin is event-driven: a post save, a
6 * settings change, a theme switch, an explicit purge. A site that fires none
7 * of those — a brochure site, a docs portal, a finished catalog — never
8 * deletes anything. Expiry still works (both serve paths age-check before
9 * using a file, so nobody is served a stale page), but the expired bodies sit
10 * on disk forever and the admin's Cache Size figure only ever climbs.
11 *
12 * Minified assets are worse: they are named by content (Minifier::
13 * rewrite_asset), so every plugin or theme update mints a new min/ file and
14 * orphans the old one permanently. The per-source manifests that map a
15 * source to its current name (min/manifests/<blog>/*.json) orphan the same
16 * way when a source goes away or its output is collected.
17 *
18 * This adds the missing time-driven collector — a daily `xspeed_gc` cron that
19 * sweeps in three phases:
20 *
21 * flat wp-content/cache/xspeed/<md5>.html per-entry TTL
22 * static wp-content/cache/xspeed-static/**\/index.html global TTL
23 * min wp-content/cache/xspeed/min/**\/*.css|js long max-age
24 * plus min/manifests/**\/*.json long max-age, orphans only
25 *
26 * Deliberately NOT swept: `rest/*.json`. A REST entry's TTL is resolved per
27 * request through the `xspeed_rest_cache_ttl` filter and is never written to
28 * disk (Rest_Cache::ttl_for), so nothing on disk tells GC when one expired.
29 *
30 * @package XSpeed
31 */
32
33 declare(strict_types=1);
34
35 namespace XSpeed;
36
37 defined( 'ABSPATH' ) || exit;
38
39 final class Cache_GC {
40
41 /** Daily cron hook. */
42 public const CRON_HOOK = 'xspeed_gc';
43
44 /** Where the resume point between capped runs is stored. */
45 public const CURSOR_OPTION = 'xspeed_gc_cursor';
46
47 /** Candidate files examined per run before the sweep pauses. */
48 public const DEFAULT_BUDGET = 5000;
49
50 /** Sweep order. A run walks these in sequence until the budget is spent. */
51 private const PHASES = array( 'flat', 'static', 'min' );
52
53 /**
54 * Register the daily event if it isn't already scheduled.
55 *
56 * Called from both CacheModule::activate() (fresh installs) and
57 * CacheModule::boot() (sites that upgraded into this version and will
58 * never run the activation hook again).
59 */
60 public static function ensure_scheduled(): void {
61 if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
62 // An hour out rather than immediately: activation already does
63 // enough filesystem work, and nothing here is urgent.
64 wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_HOOK );
65 }
66 }
67
68 /** Drop the event. Called from CacheModule::deactivate(). */
69 public static function unschedule(): void {
70 wp_clear_scheduled_hook( self::CRON_HOOK );
71 }
72
73 /**
74 * How long a minified asset may sit unused before collection.
75 *
76 * Deliberately long. These files are only rewritten when the source
77 * asset's mtime changes, so a live, still-referenced asset keeps its
78 * original mtime forever — a short max-age here would delete assets the
79 * current pages still link to. 30 days means a superseded file is
80 * collected roughly a month after the update that orphaned it.
81 *
82 * Age ALONE is not a liveness test, and this docblock used to claim it
83 * was safe because "a live one is regenerated (once) a month after it was
84 * built". That is wrong: regeneration only happens on a cache MISS, when
85 * PHP runs the enqueue pipeline. On a HIT PHP never boots, so nothing
86 * regenerates and the page keeps serving a dead link — the mitigation
87 * failed precisely on the well-cached sites it was meant to protect.
88 * `is_referenced()` is the actual guard; this max-age only decides when
89 * an UNREFERENCED file is collected. (#190)
90 *
91 * A filter returning <= 0 disables the min/ phase rather than deleting
92 * everything — "no max age" is the safer reading of an unset value.
93 */
94 public static function asset_max_age(): int {
95 /**
96 * Filter the max-age (seconds) for minified/combined assets.
97 *
98 * @param int $max_age Default 30 days.
99 */
100 return (int) apply_filters( 'xspeed_gc_asset_max_age', 30 * DAY_IN_SECONDS );
101 }
102
103 /** Candidate files a single run may examine. */
104 public static function budget(): int {
105 /**
106 * Filter the per-run cap on files examined.
107 *
108 * The sweep stops once this many candidates have been looked at and
109 * resumes from the same point on the next run, so a site with
110 * hundreds of thousands of entries can't blow the cron timeout.
111 *
112 * @param int $budget Default 5000.
113 */
114 return max( 1, (int) apply_filters( 'xspeed_gc_budget', self::DEFAULT_BUDGET ) );
115 }
116
117 /**
118 * Run one bounded sweep.
119 *
120 * @param string $cause Who asked, for the activity log.
121 * @return int Files removed (parents only; .meta/.br siblings are not
122 * counted, matching purge_all()).
123 */
124 public static function run( string $cause = 'scheduled' ): int {
125 $budget = self::budget();
126 $cursor = self::read_cursor();
127 $removed = 0;
128
129 // Rebuild the "which assets are still linked" index per run. Memoized
130 // within a run (a sweep examines many files), but never across runs —
131 // pages are written and purged between ticks, and a stale index would
132 // either protect an orphan forever or, worse, fail to protect a live
133 // asset. (#190)
134 self::reset_reference_index();
135
136 // Resolve the global TTL once — Settings_Manager::get() is cheap but
137 // this runs per candidate otherwise.
138 $opts = Settings_Manager::get( 'cache' );
139 $default_ttl = max( 1, (int) ( $opts['cache_expiry'] ?? \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXPIRY_HOURS ) ) * HOUR_IN_SECONDS;
140 $asset_ttl = self::asset_max_age();
141 $now = time();
142
143 // Start at the phase we paused in and carry on round the list. Each
144 // completed phase resets the cursor and moves to the next; when the
145 // last one completes we wrap back to the first, so the next run
146 // starts a fresh cycle.
147 $start = array_search( $cursor['phase'], self::PHASES, true );
148 $start = false === $start ? 0 : (int) $start;
149 $after = (string) $cursor['after'];
150
151 for ( $i = $start; $i < count( self::PHASES ); $i++ ) {
152 $phase = self::PHASES[ $i ];
153
154 if ( 'min' === $phase && $asset_ttl <= 0 ) {
155 $after = '';
156 continue;
157 }
158
159 list( $phase_removed, $stopped_at ) = self::sweep_phase( $phase, $after, $budget, $now, $default_ttl, $asset_ttl );
160 $removed += $phase_removed;
161
162 if ( '' !== $stopped_at ) {
163 // Budget spent mid-phase — remember where to pick up.
164 self::write_cursor( $phase, $stopped_at );
165 self::finish( $removed, $cause );
166 return $removed;
167 }
168
169 // Phase complete. The static tree can now be pruned of the
170 // directories the sweep emptied — safe only once the whole tree
171 // has been walked, and bounded because it happens at most once
172 // per full cycle.
173 if ( 'static' === $phase && defined( 'XSPEED_CACHE_STATIC_DIR' ) ) {
174 self::prune_empty_dirs( XSPEED_CACHE_STATIC_DIR );
175 }
176
177 $after = '';
178 }
179
180 // Full cycle done — rewind to the first phase.
181 self::write_cursor( self::PHASES[0], '' );
182 self::finish( $removed, $cause );
183 return $removed;
184 }
185
186 /**
187 * Sweep one phase.
188 *
189 * @param string $phase One of self::PHASES.
190 * @param string $after Resume point (absolute path) or ''.
191 * @param int $budget Remaining candidate budget, decremented.
192 * @param int $now Run timestamp.
193 * @param int $default_ttl Global page TTL in seconds.
194 * @param int $asset_ttl Minified-asset max-age in seconds.
195 * @return array{0:int,1:string} Removed count, and the path the sweep
196 * stopped at ('' when the phase finished).
197 */
198 private static function sweep_phase( string $phase, string $after, int &$budget, int $now, int $default_ttl, int $asset_ttl ): array {
199 $root = self::phase_root( $phase );
200 if ( null === $root || ! is_dir( $root ) ) {
201 return array( 0, '' );
202 }
203
204 $removed = 0;
205
206 // Every phase descends now. The flat phase used to walk only the top
207 // level, back when entries lived directly in XSPEED_CACHE_DIR — but
208 // per-site buckets moved every entry one level down (or two, for a
209 // subdirectory-multisite subsite), so a non-recursive walk stopped
210 // seeing the only layout that exists and GC silently expired nothing.
211 // On single sites too: their entries are bucketed under the host as
212 // well. is_candidate() is what keeps min/ and rest/ out, so recursing
213 // here does not pull them in. (QA B1 on #166)
214 foreach ( self::files( $root, true ) as $path ) {
215 // Cheap name test first: a non-candidate costs no stat and no
216 // budget. Everything else in these directories (index.php,
217 // .meta, .br, .mobile-separate, the hits log) is either a
218 // sibling collected with its parent or must never be touched.
219 if ( ! self::is_candidate( $phase, $path ) ) {
220 continue;
221 }
222 // Skip everything already handled in an earlier run. String
223 // compare only — self::files() yields in a stable sorted order.
224 if ( '' !== $after && strcmp( $path, $after ) <= 0 ) {
225 continue;
226 }
227 if ( $budget <= 0 ) {
228 // Paused before examining $path. $after is the last candidate
229 // we did examine, which is exactly where to resume.
230 return array( $removed, $after );
231 }
232 --$budget;
233 $after = $path;
234
235 $max_age = 'min' === $phase ? $asset_ttl : self::page_max_age( $phase, $path, $default_ttl );
236 if ( ! self::is_stale( $path, $now, $max_age ) ) {
237 continue;
238 }
239
240 // A manifest is never linked from a page, so the reference index
241 // has nothing to say about it. It goes only when it can no longer
242 // be used: its source is gone, or the output it names is.
243 if ( 'min' === $phase && '.json' === substr( $path, -5 ) ) {
244 if ( self::manifest_is_orphan( $path ) ) {
245 wp_delete_file( $path );
246 ++$removed;
247 }
248 continue;
249 }
250
251 // An asset a live cached page still links to is NOT collectable,
252 // however old it is. Age is a hint about orphanhood; this is the
253 // fact. Without it GC deleted files every cached page pointed at
254 // and left the pages in place, so the site served 200s full of
255 // 404s. (#190)
256 if ( 'min' === $phase && self::is_referenced( $path ) ) {
257 continue;
258 }
259
260 self::delete_entry( $path );
261 ++$removed;
262 }
263
264 return array( $removed, '' );
265 }
266
267 /** Absolute root directory for a phase, or null when undefined. */
268 private static function phase_root( string $phase ): ?string {
269 switch ( $phase ) {
270 case 'flat':
271 return defined( 'XSPEED_CACHE_DIR' ) ? XSPEED_CACHE_DIR : null;
272 case 'static':
273 return defined( 'XSPEED_CACHE_STATIC_DIR' ) ? XSPEED_CACHE_STATIC_DIR : null;
274 case 'min':
275 return defined( 'XSPEED_CACHE_DIR' ) ? XSPEED_CACHE_DIR . '/min' : null;
276 }
277 return null;
278 }
279
280 /**
281 * Is this file one the given phase collects?
282 *
283 * The flat phase deliberately ignores subdirectories — min/ and rest/
284 * live under XSPEED_CACHE_DIR and have their own rules (or none).
285 */
286 private static function is_candidate( string $phase, string $path ): bool {
287 $name = basename( $path );
288 switch ( $phase ) {
289 case 'flat':
290 /*
291 * Flat entries live in a per-site bucket since #6:
292 *
293 * <cache>/<host>/<md5>.html single site, main blog
294 * <cache>/<host>/<prefix>/<md5>.html subdirectory subsite
295 *
296 * Both depths must be accepted — the two-level form is where a
297 * subdirectory-multisite subsite's pages live, and accepting
298 * only one level left them uncollectable. The legacy top-level
299 * layout stays accepted so entries written before #6 still age
300 * out instead of lingering forever. (QA B1 on #166)
301 *
302 * Depth alone is not the guard against min/ and rest/: those
303 * are excluded by name, at either level, because the sweep now
304 * recurses and would otherwise treat their contents as pages.
305 */
306 if ( '.html' !== substr( $name, -5 ) ) {
307 return false;
308 }
309 $parent = dirname( $path );
310 $depth1 = $parent === XSPEED_CACHE_DIR;
311 $depth2 = dirname( $parent ) === XSPEED_CACHE_DIR;
312 $depth3 = dirname( dirname( $parent ) ) === XSPEED_CACHE_DIR;
313 if ( ! $depth1 && ! $depth2 && ! $depth3 ) {
314 return false;
315 }
316 // Walk up to the cache root looking for a reserved directory,
317 // so `min/` and `rest/` are excluded however deep we are.
318 for ( $dir = $parent; strlen( $dir ) > strlen( XSPEED_CACHE_DIR ); $dir = dirname( $dir ) ) {
319 if ( in_array( basename( $dir ), array( 'min', 'rest' ), true ) ) {
320 return false;
321 }
322 }
323 return true;
324 case 'static':
325 return 'index.html' === $name;
326 case 'min':
327 if ( '.json' === substr( $name, -5 ) ) {
328 $manifests = self::phase_root( 'min' ) . '/' . Asset_Manifest::SUBDIR . '/';
329 return 0 === strpos( $path, $manifests );
330 }
331 return '.css' === substr( $name, -4 ) || '.js' === substr( $name, -3 );
332 }
333 return false;
334 }
335
336 /**
337 * Is this manifest useless now?
338 *
339 * True when its source no longer exists, or when it names a minified
340 * output that is no longer on disk. The second is safe to drop even while
341 * the source lives: the next render rebuilds both. A combine-part
342 * manifest names no output, so only its source decides. An unreadable
343 * manifest is useless by definition.
344 *
345 * @param string $path Absolute manifest path.
346 */
347 private static function manifest_is_orphan( string $path ): bool {
348 $manifest = Asset_Manifest::read( $path );
349 if ( null === $manifest || empty( $manifest['src'] ) || ! is_string( $manifest['src'] ) ) {
350 return true;
351 }
352 if ( ! file_exists( $manifest['src'] ) ) {
353 return true;
354 }
355 $root = self::phase_root( 'min' );
356 $key = isset( $manifest['key'] ) && is_string( $manifest['key'] ) ? $manifest['key'] : '';
357 if ( '' === $key || null === $root ) {
358 return true;
359 }
360 $kind = isset( $manifest['kind'] ) && is_string( $manifest['kind'] ) ? $manifest['kind'] : '';
361 if ( 'css' === $kind || 'js' === $kind ) {
362 return ! file_exists( $root . '/' . $key . '.' . $kind );
363 }
364 return false;
365 }
366
367 /**
368 * Effective max-age for a cached page, in seconds.
369 *
370 * Cache::is_expired() is the read-time gate and is deliberately NOT
371 * reused here: it resolves the per-post override from the *current*
372 * request (Cache_Rules::current_post_id() is null in cron) and runs the
373 * `xspeed_cache_max_age` filter, whose Pro listeners branch on
374 * is_404()/is_feed() of the request being served. Both are meaningless
375 * on a cron tick and would mis-age every entry.
376 *
377 * The authoritative per-entry value is the `ttl` written into the .meta
378 * sidecar at store time (Cache::write_meta), which is exactly the
379 * resolved max-age for that entry — that is what feeds and 404s carry.
380 * Entries with the default TTL write no sidecar, hence the fallback.
381 *
382 * The static tree never has a .meta: store_static() only runs for plain
383 * 200 text/html, so the global TTL is always correct there.
384 */
385 private static function page_max_age( string $phase, string $path, int $default_ttl ): int {
386 if ( 'static' === $phase ) {
387 // A nonce-bearing page records its own deadline when written: the
388 // nonce dies on WordPress's schedule, not the site's cache
389 // lifetime, and this tree is served without PHP so nothing else
390 // can enforce it. A site caching for a week would otherwise hand
391 // out a dead nonce for six and a half days of it, breaking every
392 // anonymous form on the page.
393 $expires_file = dirname( $path ) . '/.xspeed-expires';
394 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- sidecar read on a cron sweep; WP_Filesystem is not loaded here.
395 $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.
396 if ( $expires > 0 ) {
397 $mtime = @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- false is handled below.
398 // Express the deadline as an age, since the caller compares
399 // against the file's own mtime. A file already past its
400 // deadline gets 0, which expires it on this sweep.
401 return ( false !== $mtime ) ? max( 0, $expires - $mtime ) : 0;
402 }
403
404 return $default_ttl;
405 }
406 if ( 'flat' !== $phase ) {
407 return $default_ttl;
408 }
409 // Read the sidecar NEXT TO THE FILE. Cache::read_meta() rebuilds the
410 // path from the key via cache_meta_for(), which resolves against the
411 // CURRENT request's site bucket — wrong for a cron sweep walking
412 // every site's entries, and wrong for the legacy top-level layout.
413 // The sidecar is always `<file>.meta`, so derive it directly. (#6)
414 $meta_file = substr( $path, 0, -5 ) . '.meta';
415 $ttl = 0;
416 if ( is_file( $meta_file ) ) {
417 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- our own cache sidecar; WP_Filesystem needs admin credentials unavailable during cron.
418 $raw = (string) @file_get_contents( $meta_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable sidecar just means "use the global TTL".
419 $decoded = json_decode( $raw, true );
420 if ( is_array( $decoded ) && isset( $decoded['ttl'] ) ) {
421 $ttl = (int) $decoded['ttl'];
422 }
423 }
424 return $ttl > 0 ? $ttl : $default_ttl;
425 }
426
427 /**
428 * Age test. A file that vanished between the scan and here (a concurrent
429 * purge, a parallel cron) is not stale — there is nothing to delete.
430 * A future mtime (clock skew, rsync -t from a fast host) reads as age 0,
431 * so it is kept rather than collected.
432 */
433 private static function is_stale( string $path, int $now, int $max_age ): bool {
434 if ( $max_age <= 0 ) {
435 return false;
436 }
437 clearstatcache( true, $path );
438 $mtime = @filemtime( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- file may have been removed concurrently; false is handled below.
439 if ( false === $mtime ) {
440 return false;
441 }
442 return ( $now - (int) $mtime ) > $max_age;
443 }
444
445 /**
446 * Asset paths (relative to `min/`) that some cached page still links to.
447 *
448 * Built once per run and memoized: a sweep examines up to `budget()`
449 * files, and re-reading every cached page for each of them would turn a
450 * cheap cron tick into an O(assets x pages) crawl.
451 *
452 * Scans BOTH cache trees. The static tree is served by nginx without ever
453 * running PHP, so a page there can outlive any invalidation we do in PHP —
454 * missing it would leave exactly the 404s this fix exists to prevent, on
455 * the fastest path.
456 *
457 * @var array<string,true>|null
458 */
459 private static $referenced = null;
460
461 /** Forget the memo — the next run rebuilds it. */
462 public static function reset_reference_index(): void {
463 self::$referenced = null;
464 }
465
466 /**
467 * Is this asset linked from any cached page?
468 *
469 * @param string $path Absolute path to a file under `min/`.
470 */
471 private static function is_referenced( string $path ): bool {
472 if ( null === self::$referenced ) {
473 self::$referenced = self::build_reference_index();
474 }
475
476 $min_root = self::phase_root( 'min' );
477 if ( null === $min_root ) {
478 return false;
479 }
480 // Compare on the path RELATIVE to min/, which is what a page's URL
481 // carries — absolute paths differ between the cache dir and the URL.
482 $rel = ltrim( str_replace( $min_root, '', $path ), '/' );
483
484 return isset( self::$referenced[ $rel ] );
485 }
486
487 /**
488 * Read every cached page once and collect the assets they reference.
489 *
490 * @return array<string,true> Keys are paths relative to `min/`.
491 */
492 private static function build_reference_index(): array {
493 $found = array();
494
495 foreach ( array( 'flat', 'static' ) as $phase ) {
496 $root = self::phase_root( $phase );
497 if ( null === $root || ! is_dir( $root ) ) {
498 continue;
499 }
500 foreach ( self::files( $root, 'flat' !== $phase ) as $file ) {
501 if ( '.html' !== substr( $file, -5 ) ) {
502 continue;
503 }
504 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading our own cache file; WP_Filesystem is unavailable in cron context.
505 $html = (string) @file_get_contents( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a concurrent purge can unlink mid-walk; '' is handled.
506 if ( '' === $html ) {
507 continue;
508 }
509 if ( ! preg_match_all( '#/cache/xspeed/min/([^"\'\s?>]+\.(?:css|js))#', $html, $m ) ) {
510 continue;
511 }
512 foreach ( $m[1] as $rel ) {
513 $found[ $rel ] = true;
514 }
515 }
516 }
517
518 return $found;
519 }
520
521 /**
522 * Delete a cache entry and every sibling that only exists because of it,
523 * so the sweep never creates the orphans it is there to remove:
524 *
525 * <md5>.html → <md5>.meta, <md5>.html.br
526 * index.html → index.html.br
527 * <key>.css/js → (none)
528 */
529 private static function delete_entry( string $path ): void {
530 wp_delete_file( $path );
531
532 $br = $path . '.br';
533 if ( file_exists( $br ) ) {
534 wp_delete_file( $br );
535 }
536
537 if ( '.html' === substr( $path, -5 ) ) {
538 $meta = substr( $path, 0, -5 ) . '.meta';
539 if ( file_exists( $meta ) ) {
540 wp_delete_file( $meta );
541 }
542 }
543
544 // Deleting an asset and invalidating the pages that embed it are ONE
545 // operation, so the two caches can never disagree. is_referenced()
546 // already keeps a linked asset alive, so this is the belt to that
547 // braces: it covers the races the index cannot see — a page written
548 // after the index was built, or a reference in a form the scan did
549 // not match. Without it, any gap between the two caches shows up as a
550 // 200 page full of 404s. (#190 AC2)
551 $min_root = self::phase_root( 'min' );
552 if ( null !== $min_root && 0 === strpos( $path, $min_root . '/' ) ) {
553 self::purge_pages_referencing( ltrim( str_replace( $min_root, '', $path ), '/' ) );
554 }
555 }
556
557 /**
558 * Remove every cached page that links to the given asset.
559 *
560 * Walks both trees: the static one is served by nginx without PHP, so a
561 * page left there keeps serving the dead link no matter what the flat
562 * cache says.
563 *
564 * @param string $rel Asset path relative to `min/`.
565 */
566 private static function purge_pages_referencing( string $rel ): void {
567 if ( '' === $rel ) {
568 return;
569 }
570
571 foreach ( array( 'flat', 'static' ) as $phase ) {
572 $root = self::phase_root( $phase );
573 if ( null === $root || ! is_dir( $root ) ) {
574 continue;
575 }
576 foreach ( self::files( $root, 'flat' !== $phase ) as $file ) {
577 if ( '.html' !== substr( $file, -5 ) ) {
578 continue;
579 }
580 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading our own cache file; WP_Filesystem is unavailable in cron context.
581 $html = (string) @file_get_contents( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- concurrent purge can unlink mid-walk.
582 if ( '' === $html || false === strpos( $html, $rel ) ) {
583 continue;
584 }
585
586 wp_delete_file( $file );
587 foreach ( array( $file . '.br', substr( $file, 0, -5 ) . '.meta' ) as $sibling ) {
588 if ( file_exists( $sibling ) ) {
589 wp_delete_file( $sibling );
590 }
591 }
592 }
593 }
594 }
595
596 /**
597 * Yield every file under $dir, depth-first, in a stable order.
598 *
599 * Stable matters: the resume cursor is a path comparison, so two runs
600 * must agree on the sequence. scandir() sorts by default; the explicit
601 * recursion keeps directories and files interleaved in that same order.
602 *
603 * @param string $dir Directory to walk.
604 * @param bool $recursive Descend into subdirectories.
605 * @return \Generator<string>
606 */
607 private static function files( string $dir, bool $recursive = true ): \Generator {
608 $entries = @scandir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- unreadable directory is not fatal; empty walk is the right answer.
609 if ( false === $entries ) {
610 return;
611 }
612 foreach ( $entries as $entry ) {
613 if ( '.' === $entry || '..' === $entry ) {
614 continue;
615 }
616 $path = $dir . '/' . $entry;
617 if ( is_dir( $path ) ) {
618 if ( $recursive ) {
619 yield from self::files( $path );
620 }
621 continue;
622 }
623 yield $path;
624 }
625 }
626
627 /**
628 * Remove directories the sweep emptied, bottom-up. Returns true when
629 * $dir itself is now gone. The root is kept — nginx's access_log target
630 * and the silence file live beside it and callers assume it exists.
631 */
632 private static function prune_empty_dirs( string $root ): void {
633 $entries = @scandir( $root ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see files().
634 if ( false === $entries ) {
635 return;
636 }
637 foreach ( $entries as $entry ) {
638 if ( '.' === $entry || '..' === $entry ) {
639 continue;
640 }
641 $path = $root . '/' . $entry;
642 if ( is_dir( $path ) ) {
643 self::prune_empty_dirs( $path );
644 // Best-effort: a non-empty directory simply refuses.
645 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- mirrors Cache::rmtree_html(); WP_Filesystem needs admin credentials unavailable on a cron tick.
646 @rmdir( $path );
647 }
648 }
649 }
650
651 /** Persisted resume point: which phase, and the last path examined. */
652 private static function read_cursor(): array {
653 $stored = get_option( self::CURSOR_OPTION, array() );
654 if ( ! is_array( $stored ) ) {
655 $stored = array();
656 }
657 $phase = isset( $stored['phase'] ) && in_array( $stored['phase'], self::PHASES, true )
658 ? (string) $stored['phase']
659 : self::PHASES[0];
660
661 return array(
662 'phase' => $phase,
663 'after' => isset( $stored['after'] ) && is_string( $stored['after'] ) ? $stored['after'] : '',
664 );
665 }
666
667 private static function write_cursor( string $phase, string $after ): void {
668 $value = array(
669 'phase' => $phase,
670 'after' => $after,
671 );
672 if ( false === get_option( self::CURSOR_OPTION, false ) ) {
673 add_option( self::CURSOR_OPTION, $value, '', 'no' );
674 return;
675 }
676 update_option( self::CURSOR_OPTION, $value );
677 }
678
679 /**
680 * Record the run so the Cache section can show it without SSH, and drop
681 * the memoized inventory when anything actually went away.
682 */
683 private static function finish( int $removed, string $cause ): void {
684 $stats = Cache::get_stats_option();
685 Cache::update_stats(
686 array(
687 'last_gc' => time(),
688 'gc_removed' => $removed,
689 'gc_removed_total' => (int) ( $stats['gc_removed_total'] ?? 0 ) + $removed,
690 )
691 );
692
693 if ( $removed < 1 ) {
694 return;
695 }
696
697 Cache_Inventory::invalidate();
698
699 Activity_Log::record(
700 'cache_purged',
701 sprintf(
702 /* translators: 1: cause of the sweep, 2: number of files removed. */
703 __( 'Cache garbage collection (%1$s) — %2$d expired file(s) removed', 'xspeed' ),
704 $cause,
705 $removed
706 ),
707 Activity_Log::INFO
708 );
709 }
710 }
711