Coverage Report

Created: 2026-07-21 15:28

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-worker/src/directory_cache.rs
Line
Count
Source
1
// Copyright 2024 The NativeLink Authors. All rights reserved.
2
//
3
// Licensed under the Apache License, Version 2.0 (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//    http://www.apache.org/licenses/LICENSE-2.0
8
//
9
// Unless required by applicable law or agreed to in writing, software
10
// distributed under the License is distributed on an "AS IS" BASIS,
11
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
// See the License for the specific language governing permissions and
13
// limitations under the License.
14
15
use core::future::Future;
16
use core::pin::Pin;
17
use core::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
18
use std::collections::HashMap;
19
use std::path::{Path, PathBuf};
20
use std::sync::Arc;
21
use std::time::{SystemTime, UNIX_EPOCH};
22
23
use futures::StreamExt;
24
use futures::future::BoxFuture;
25
use futures::stream::TryStreamExt;
26
use nativelink_error::{Code, Error, ResultExt, make_err};
27
use nativelink_metric::{
28
    MetricFieldData, MetricKind, MetricPublishKnownKindData, MetricsComponent, group, publish,
29
};
30
use nativelink_proto::build::bazel::remote::execution::v2::{
31
    Directory as ProtoDirectory, DirectoryNode, FileNode, SymlinkNode,
32
};
33
use nativelink_store::ac_utils::get_and_decode_digest;
34
use nativelink_store::cas_utils::is_zero_digest;
35
use nativelink_store::fast_slow_store::FastSlowStore;
36
use nativelink_store::filesystem_store::{FileEntry, FilesystemStore};
37
use nativelink_util::background_spawn;
38
use nativelink_util::common::DigestInfo;
39
use nativelink_util::fs_util::{CloneMethod, hardlink_directory_tree, set_dir_writable_recursive};
40
use nativelink_util::store_trait::{StoreKey, StoreLike};
41
use tokio::fs;
42
43
/// Maximum number of concurrently-polled node materializations (file
44
/// fetches, subdirectory recursions, symlink creations) per directory
45
/// level. Matches `DOWNLOAD_TO_DIRECTORY_CONCURRENCY` in
46
/// `running_actions_manager.rs`: enough parallelism to overlap slow-store
47
/// round trips, bounded so hardlink/copy syscalls do not fight the
48
/// filesystem's metadata locks (see the gate comment in
49
/// `download_to_directory`).
50
const CONSTRUCT_DIRECTORY_CONCURRENCY: usize = 64;
51
52
/// Default for `DirectoryCacheConfig::max_concurrent_fetches`, preserving
53
/// the historical cache-wide fetch bound.
54
const DEFAULT_MAX_CONCURRENT_FETCHES: usize = 64;
55
use tokio::sync::{Mutex, RwLock, Semaphore};
56
use tracing::{debug, info, trace, warn};
57
58
/// Prefix for in-progress construction trees under `cache_root`. Cache-entry
59
/// names are digest strings (hex), so dot-prefixed scratch names can never
60
/// collide with a published entry.
61
const TEMP_PREFIX: &str = ".tmp-";
62
63
/// Prefix for eviction tombstones awaiting background deletion.
64
const TOMBSTONE_PREFIX: &str = ".del-";
65
66
/// Minimum interval between summary log lines, in nanoseconds (one minute).
67
const SUMMARY_LOG_INTERVAL_NANOS: u64 = 60_000_000_000;
68
69
/// Current time as nanoseconds since the unix epoch, for lock-free LRU
70
/// timestamps. Saturates instead of failing (`u64` nanos overflow in 2554).
71
299
fn unix_nanos_now() -> u64 {
72
299
    SystemTime::now()
73
299
        .duration_since(UNIX_EPOCH)
74
299
        .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX))
75
299
}
76
77
/// Configuration for the directory cache
78
#[derive(Debug, Clone)]
79
pub struct DirectoryCacheConfig {
80
    /// Maximum number of cached directories
81
    pub max_entries: usize,
82
    /// Maximum total size in bytes (0 = unlimited)
83
    pub max_size_bytes: u64,
84
    /// Base directory for cache storage
85
    pub cache_root: PathBuf,
86
    /// Experimental: additionally cache every subdirectory by its own
87
    /// `Directory` digest so unchanged subtrees are shared across different
88
    /// root digests. Subtree entries participate in normal eviction and
89
    /// multiply the entry count, so `max_entries` should be raised when
90
    /// enabling this. Default: false (root-only caching, existing behavior).
91
    pub experimental_subtree_caching: bool,
92
    /// Maximum number of concurrent slow-store fetches across ALL directory
93
    /// constructions of this cache. Permits are held only across leaf I/O
94
    /// (directory-proto fetches, blob fetches/populates) and never across
95
    /// subdirectory recursion, so recursion depth cannot deadlock the
96
    /// semaphore. This is the bound that protects backing stores from RPC
97
    /// storms: per-level construction concurrency compounds multiplicatively
98
    /// across tree levels and concurrent actions (measured >1800 in-flight
99
    /// fetches for 6 concurrent ~500-file trees without this cap). Low
100
    /// values fragment coalesced/batched reads to at most this many items;
101
    /// see the config-crate doc for the `experimental_read_batching`
102
    /// interaction. Must be > 0. Default: 64.
103
    pub max_concurrent_fetches: usize,
104
}
105
106
impl Default for DirectoryCacheConfig {
107
25
    fn default() -> Self {
108
25
        Self {
109
25
            max_entries: 1000,
110
25
            max_size_bytes: 10 * 1024 * 1024 * 1024, // 10 GB
111
25
            cache_root: std::env::temp_dir().join("nativelink_directory_cache"),
112
25
            experimental_subtree_caching: false,
113
25
            max_concurrent_fetches: DEFAULT_MAX_CONCURRENT_FETCHES,
114
25
        }
115
25
    }
116
}
117
118
/// Metadata for a cached directory
119
#[derive(Debug)]
120
struct CachedDirectoryMetadata {
121
    /// Path to the cached directory
122
    path: PathBuf,
123
    /// Recorded size in bytes. With subtree caching enabled this covers only
124
    /// bytes not owned by a descendant entry, so the sum over the map
125
    /// approximates unique materialized bytes.
126
    size: u64,
127
    /// Last access time (nanoseconds since the unix epoch) for LRU eviction.
128
    /// Atomic so cache hits can refresh it under the cache READ lock instead
129
    /// of serializing on the write lock.
130
    last_access: AtomicU64,
131
    /// Number of active users. Shared with the [`EntryPin`] RAII guards
132
    /// handed out by `acquire_entry`, which decrement it on drop — even when
133
    /// the future holding the pin is cancelled mid-await. Eviction skips
134
    /// entries with a non-zero count.
135
    ref_count: Arc<AtomicUsize>,
136
}
137
138
/// RAII pin on a cache entry: while alive, eviction will not select the
139
/// entry, so its on-disk tree cannot be deleted out from under an unlocked
140
/// materialization. Dropping the pin releases it — including implicitly when
141
/// a construction future is cancelled (e.g. `buffer_unordered` dropping
142
/// sibling futures after another node errors), so pins can never leak and
143
/// permanently block eviction.
144
#[derive(Debug)]
145
struct EntryPin {
146
    ref_count: Arc<AtomicUsize>,
147
}
148
149
impl Drop for EntryPin {
150
143
    fn drop(&mut self) {
151
143
        self.ref_count.fetch_sub(1, Ordering::SeqCst);
152
143
    }
153
}
154
155
/// Drop guard for an in-progress temp construction tree: if the owning
156
/// future is dropped before [`Self::disarm`] (cancellation — e.g.
157
/// `buffer_unordered` drops sibling constructions on the first error), the
158
/// partial tree is deleted in the background. Error paths instead disarm and
159
/// clean up inline so a failed construction leaves nothing behind by the
160
/// time the error propagates.
161
#[derive(Debug)]
162
struct ScratchGuard {
163
    path: Option<PathBuf>,
164
}
165
166
impl ScratchGuard {
167
111
    const fn new(path: PathBuf) -> Self {
168
111
        Self { path: Some(path) }
169
111
    }
170
171
108
    fn disarm(&mut self) {
172
108
        self.path = None;
173
108
    }
174
}
175
176
impl Drop for ScratchGuard {
177
111
    fn drop(&mut self) {
178
111
        if let Some(
path3
) = self.path.take() {
179
3
            DirectoryCache::dispatch_evictions(vec![path]);
180
108
        }
181
111
    }
182
}
183
184
/// High-performance directory cache that uses hardlinks to avoid repeated
185
/// directory reconstruction from the CAS.
186
///
187
/// When actions need input directories, instead of fetching and reconstructing
188
/// files from the CAS each time, we:
189
/// 1. Check if we've already constructed this exact directory (by digest)
190
/// 2. If yes, hardlink the entire tree to the action's workspace
191
/// 3. If no, construct it once and cache for future use
192
///
193
/// This dramatically reduces I/O and improves action startup time.
194
#[derive(Debug)]
195
pub struct DirectoryCache {
196
    /// Configuration
197
    config: DirectoryCacheConfig,
198
    /// Cache mapping digest -> metadata
199
    cache: Arc<RwLock<HashMap<DigestInfo, CachedDirectoryMetadata>>>,
200
    /// Lock for cache construction to prevent stampedes
201
    construction_locks: Arc<Mutex<HashMap<DigestInfo, Arc<Mutex<()>>>>>,
202
    /// CAS store for fetching directory protos and (fallback) file content.
203
    cas_store: Arc<FastSlowStore>,
204
    /// The `FastSlowStore`'s fast tier, if it is a `FilesystemStore`. When
205
    /// present, CAS blobs can be hardlinked directly into the cache entry
206
    /// (zero-copy) instead of fetched into RAM and rewritten. When absent
207
    /// (e.g. an unusual store layout) the cache falls back to fetch+write.
208
    filesystem_store: Option<Arc<FilesystemStore>>,
209
    /// Global slow-fetch budget shared by all constructions of this cache;
210
    /// see `DirectoryCacheConfig::max_concurrent_fetches`.
211
    fetch_permits: Semaphore,
212
    /// Count of materializations that used APFS `clonefile(2)` (macOS only;
213
    /// always zero on other platforms).
214
    clonefile_hits: AtomicU64,
215
    /// Count of materializations that used per-file `fs::hard_link`.
216
    hardlink_hits: AtomicU64,
217
    /// Count of subtree materializations served from the cache by the
218
    /// subtree's own `Directory` digest. Only ever non-zero when
219
    /// `experimental_subtree_caching` is enabled.
220
    subtree_hits: AtomicU64,
221
    /// Count of subtree constructions that could not be served from the
222
    /// cache. Only ever non-zero when `experimental_subtree_caching` is
223
    /// enabled.
224
    subtree_misses: AtomicU64,
225
    /// Count of entries removed by LRU eviction. A high rate relative to
226
    /// hits means the cache is thrashing (`max_entries`/`max_size_bytes` too
227
    /// small for the workload — especially with subtree caching enabled,
228
    /// which multiplies the entry count).
229
    evictions: AtomicU64,
230
    /// Monotonic counter for process-unique scratch names (temp construction
231
    /// trees and eviction tombstones) under `cache_root`.
232
    scratch_seq: AtomicU64,
233
    /// Mirror of the map's entry count, maintained at insert/evict/invalidate
234
    /// so the sync `MetricsComponent::publish` and the rate-limited log
235
    /// summary can read it without touching the async `RwLock`.
236
    map_entries: AtomicU64,
237
    /// Mirror of the map's total recorded size in bytes (see `map_entries`).
238
    map_size_bytes: AtomicU64,
239
    /// Timestamp (unix nanos) of the last summary log line, for rate
240
    /// limiting.
241
    last_summary_log: AtomicU64,
242
}
243
244
impl DirectoryCache {
245
    /// Creates a new `DirectoryCache`.
246
    ///
247
    /// `cas_store` is the worker's `FastSlowStore`. Its fast tier is expected
248
    /// to be a `FilesystemStore`; when it is, `construct_directory` hardlinks
249
    /// CAS blobs directly into the cache entry instead of copying them.
250
25
    pub async fn new(
251
25
        config: DirectoryCacheConfig,
252
25
        cas_store: Arc<FastSlowStore>,
253
25
    ) -> Result<Self, Error> {
254
        // A zero-permit semaphore would deadlock every construction.
255
25
        if config.max_concurrent_fetches == 0 {
256
0
            return Err(make_err!(
257
0
                Code::InvalidArgument,
258
0
                "directory_cache max_concurrent_fetches must be greater than 0"
259
0
            ));
260
25
        }
261
25
        let max_concurrent_fetches = config.max_concurrent_fetches;
262
263
        // Ensure cache root exists
264
25
        fs::create_dir_all(&config.cache_root).await.err_tip(|| 
{0
265
0
            format!(
266
                "Failed to create cache root: {}",
267
0
                config.cache_root.display()
268
            )
269
0
        })?;
270
271
        // Mirror RunningActionsManagerImpl: the fast tier is normally a
272
        // FilesystemStore. If the downcast fails the cache still works — it
273
        // just falls back to the fetch+write path for every file.
274
25
        let filesystem_store = cas_store
275
25
            .fast_store()
276
25
            .downcast_ref::<FilesystemStore>(None)
277
25
            .and_then(FilesystemStore::get_arc);
278
25
        if filesystem_store.is_none() {
279
0
            warn!(
280
                "DirectoryCache fast store is not a FilesystemStore; \
281
                 CAS blobs will be copied instead of hardlinked"
282
            );
283
25
        }
284
285
25
        let cache = Self {
286
25
            config,
287
25
            cache: Arc::new(RwLock::new(HashMap::new())),
288
25
            construction_locks: Arc::new(Mutex::new(HashMap::new())),
289
25
            cas_store,
290
25
            filesystem_store,
291
25
            fetch_permits: Semaphore::new(max_concurrent_fetches),
292
25
            clonefile_hits: AtomicU64::new(0),
293
25
            hardlink_hits: AtomicU64::new(0),
294
25
            subtree_hits: AtomicU64::new(0),
295
25
            subtree_misses: AtomicU64::new(0),
296
25
            evictions: AtomicU64::new(0),
297
25
            scratch_seq: AtomicU64::new(0),
298
25
            map_entries: AtomicU64::new(0),
299
25
            map_size_bytes: AtomicU64::new(0),
300
25
            last_summary_log: AtomicU64::new(0),
301
25
        };
302
25
        cache.sweep_cache_root().await;
303
25
        Ok(cache)
304
25
    }
305
306
    /// Sweeps pre-existing content of `cache_root`. The in-memory map starts
307
    /// empty, so anything already on disk is orphaned: entries from a
308
    /// previous process (unusable — they are not in the map, and a partial
309
    /// one would poison re-construction of its digest), plus stale temp
310
    /// trees and tombstones. Each orphan is renamed to a tombstone
311
    /// synchronously — after `new` returns, a fresh construction could
312
    /// otherwise publish to the very canonical path a detached deletion is
313
    /// about to remove — and the tombstones are deleted in the background.
314
25
    async fn sweep_cache_root(&self) {
315
25
        let mut orphans = Vec::new();
316
25
        match fs::read_dir(&self.config.cache_root).await {
317
25
            Ok(mut dir) => loop {
318
25
                match dir.next_entry().await {
319
0
                    Ok(Some(entry)) => orphans.push(entry.path()),
320
25
                    Ok(None) => break,
321
0
                    Err(e) => {
322
0
                        warn!(error = ?e, "Failed reading cache root during orphan sweep");
323
0
                        break;
324
                    }
325
                }
326
            },
327
0
            Err(e) => {
328
0
                warn!(error = ?e, "Failed to scan cache root for orphaned entries");
329
0
                return;
330
            }
331
        }
332
25
        if orphans.is_empty() {
333
25
            return;
334
0
        }
335
0
        debug!(
336
0
            count = orphans.len(),
337
            "Sweeping orphaned directory cache content"
338
        );
339
0
        let tombstones = self.tombstone_victims(orphans).await;
340
0
        Self::dispatch_evictions(tombstones);
341
25
    }
342
343
    /// Acquires a permit from the cache-wide slow-fetch budget. Held only
344
    /// across leaf I/O awaits, never across subdirectory recursion.
345
1.64k
    async fn acquire_fetch_permit(&self) -> Result<tokio::sync::SemaphorePermit<'_>, Error> {
346
1.64k
        self.fetch_permits
347
1.64k
            .acquire()
348
1.64k
            .await
349
1.64k
            .map_err(|e| 
make_err!0
(
Code::Internal0
, "Fetch semaphore closed: {e:?}"))
350
1.64k
    }
351
352
    /// Emits an `info!` summary of the cache counters, rate-limited to at
353
    /// most one line per [`SUMMARY_LOG_INTERVAL_NANOS`]. Called from the
354
    /// hit/miss path so deployments without a metrics pipeline can still
355
    /// verify the cache is working by grepping worker logs.
356
156
    fn maybe_log_summary(&self) {
357
156
        let now = unix_nanos_now();
358
156
        let last = self.last_summary_log.load(Ordering::Relaxed);
359
156
        if now.saturating_sub(last) < SUMMARY_LOG_INTERVAL_NANOS {
360
132
            return;
361
24
        }
362
        // Single winner per interval; losers skip the log line.
363
24
        if self
364
24
            .last_summary_log
365
24
            .compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
366
24
            .is_err()
367
        {
368
0
            return;
369
24
        }
370
24
        info!(
371
24
            clonefile_hits = self.clonefile_hits.load(Ordering::Relaxed),
372
24
            hardlink_hits = self.hardlink_hits.load(Ordering::Relaxed),
373
24
            subtree_hits = self.subtree_hits.load(Ordering::Relaxed),
374
24
            subtree_misses = self.subtree_misses.load(Ordering::Relaxed),
375
24
            evictions = self.evictions.load(Ordering::Relaxed),
376
24
            entries = self.map_entries.load(Ordering::Relaxed),
377
24
            total_size_bytes = self.map_size_bytes.load(Ordering::Relaxed),
378
            "DirectoryCache summary"
379
        );
380
156
    }
381
382
    /// Records which kernel mechanism materialized a tree, for observability.
383
140
    fn record_clone_method(&self, method: CloneMethod) {
384
140
        let counter = match method {
385
0
            CloneMethod::Clonefile => &self.clonefile_hits,
386
140
            CloneMethod::Hardlink => &self.hardlink_hits,
387
        };
388
140
        counter.fetch_add(1, Ordering::Relaxed);
389
140
    }
390
391
    /// Gets or creates a directory in the cache, then hardlinks it to the destination
392
    ///
393
    /// # Arguments
394
    /// * `digest` - Digest of the root Directory proto
395
    /// * `dest_path` - Where to hardlink/create the directory
396
    ///
397
    /// # Returns
398
    /// * `Ok(true)` - Cache hit (directory was hardlinked)
399
    /// * `Ok(false)` - Cache miss (directory was constructed)
400
    /// * `Err` - Error during construction or hardlinking
401
79
    pub async fn get_or_create(&self, digest: DigestInfo, dest_path: &Path) -> Result<bool, Error> {
402
79
        let (
hit74
,
_size74
) = self.get_or_create_entry(digest, dest_path).await
?5
;
403
74
        Ok(hit)
404
79
    }
405
406
    /// Core get-or-create flow shared by root materializations
407
    /// ([`Self::get_or_create`]) and, with `experimental_subtree_caching`
408
    /// enabled, subtree materializations (`create_subdirectory`). Returns
409
    /// `(hit, size)` where `hit` is true when the destination was served by
410
    /// hardlinking an existing cache entry, and `size` is the entry's
411
    /// recorded size in bytes.
412
156
    async fn get_or_create_entry(
413
156
        &self,
414
156
        digest: DigestInfo,
415
156
        dest_path: &Path,
416
156
    ) -> Result<(bool, u64), Error> {
417
156
        self.maybe_log_summary();
418
419
        // Fast path: serve from an existing entry.
420
156
        if let Some(
size10
) = self.try_materialize_from_cache(&digest, dest_path).await {
421
10
            return Ok((true, size));
422
146
        }
423
424
146
        debug!(?digest, "Directory cache MISS");
425
426
        // Single-flight: only one task constructs a given digest at a time.
427
        // Concurrent callers for the same digest block on this per-digest
428
        // mutex; when the constructor finishes and inserts the entry, the
429
        // waiters wake, find it in the cache, and materialize their own
430
        // destination from the single shared cache entry. Root and subtree
431
        // flights for the same digest share one mutex, and the lock order is
432
        // deadlock-free by construction: a flight only ever waits on locks
433
        // of its strict descendants, and the content-addressed Merkle DAG is
434
        // acyclic.
435
146
        let construction_lock = {
436
146
            let mut locks = self.construction_locks.lock().await;
437
146
            locks
438
146
                .entry(digest)
439
146
                .or_insert_with(|| 
Arc::new109
(
Mutex::new109
(
()109
)))
440
146
                .clone()
441
        };
442
146
        let _guard = construction_lock.lock().await;
443
444
        // Run the construction/materialization under the per-digest guard,
445
        // then drop the per-digest mutex from the stampede map regardless of
446
        // outcome so it cannot grow unbounded. The guard (`_guard`) is still
447
        // held until the end of this function — `forget_construction_lock`
448
        // only unmaps the Arc; any waiter already cloned it before blocking.
449
146
        let 
result143
= self.construct_and_materialize(digest, dest_path).await;
450
143
        self.forget_construction_lock(&digest).await;
451
143
        result
452
153
    }
453
454
    /// Attempts to serve `digest` from an existing cache entry by hardlinking
455
    /// it to `dest_path`, returning the entry's recorded size on success.
456
    ///
457
    /// The entry is pinned (see [`EntryPin`]) across the unlocked
458
    /// `hardlink_directory_tree` so eviction cannot delete its tree
459
    /// mid-materialization; only the brief refcount/LRU bookkeeping runs
460
    /// under the cache lock (the READ lock — hits never serialize on the
461
    /// write lock).
462
    ///
463
    /// Returns `None` when the digest is not cached, or when the entry's
464
    /// on-disk tree failed to hardlink (missing/corrupt). In the failure
465
    /// case the dead entry has been invalidated (removed + tombstoned, so it
466
    /// cannot fail every future request forever) and the possibly partially
467
    /// populated destination cleared, so the caller can construct fresh.
468
302
    async fn try_materialize_from_cache(
469
302
        &self,
470
302
        digest: &DigestInfo,
471
302
        dest_path: &Path,
472
302
    ) -> Option<u64> {
473
302
        let (
cache_path48
,
size48
,
pin48
) = self.acquire_entry(digest).await
?254
;
474
48
        debug!(?digest, ?cache_path, "Directory cache HIT");
475
48
        match hardlink_directory_tree(&cache_path, dest_path).await {
476
45
            Ok(method) => {
477
45
                drop(pin);
478
45
                self.record_clone_method(method);
479
45
                Some(size)
480
            }
481
3
            Err(e) => {
482
3
                warn!(
483
                    ?digest,
484
                    error = ?e,
485
                    "Failed to hardlink from cache, invalidating entry and reconstructing"
486
                );
487
3
                self.invalidate_entry(digest, &pin).await;
488
3
                drop(pin);
489
                // The failed walk may have partially populated the
490
                // destination; clear it so the reconstruction starts clean
491
                // (`hardlink_directory_tree` refuses an existing destination
492
                // and `create_file` fails on leftovers).
493
3
                Self::remove_tree_best_effort(dest_path).await;
494
3
                None
495
            }
496
        }
497
302
    }
498
499
    /// The cache-miss body, run while holding the per-digest construction
500
    /// guard. Split out so `get_or_create_entry` can unconditionally clean up
501
    /// the construction-lock map entry afterwards on every exit path.
502
146
    async fn construct_and_materialize(
503
146
        &self,
504
146
        digest: DigestInfo,
505
146
        dest_path: &Path,
506
146
    ) -> Result<(bool, u64), Error> {
507
        // Re-check: another task may have just constructed this digest while
508
        // we waited on the construction lock. If the entry turns out to be
509
        // damaged it is invalidated and we fall through to rebuild it fresh
510
        // (exactly once — we hold the construction lock).
511
146
        if let Some(
size35
) = self.try_materialize_from_cache(&digest, dest_path).await {
512
35
            return Ok((true, size));
513
111
        }
514
515
        // Construct the directory into a UNIQUE temp path first, then
516
        // atomically publish it to the canonical `cache_root/<digest>` path
517
        // via rename. Constructing at the canonical path directly would
518
        // poison the digest on failure: the partial tree left behind makes
519
        // every future construction of the digest fail on its leftovers —
520
        // and subtree digests are stable across builds, so one poisoned
521
        // popular subtree would fail every subsequent build.
522
        //
523
        // `construct_directory` returns the total tree size accumulated from
524
        // `FileNode.digest.size_bytes` as it builds — no post-hoc filesystem
525
        // walk is needed. It also sets every cache-entry directory's mode at
526
        // creation time (0o755), so no separate permission-fixup walk is
527
        // needed either.
528
        //
529
        // The cache entry's *files* are deliberately never chmod'd here:
530
        // non-executable files are hardlinks to FilesystemStore CAS blobs (see
531
        // `create_file`), and chmoding such a file mutates the inode shared
532
        // with the CAS and every other in-flight action that hardlinked the
533
        // same blob — the inode-corruption bug PR #2347 fixed.
534
111
        let cache_path = self.get_cache_path(&digest);
535
111
        let temp_path = self.allocate_scratch_path(TEMP_PREFIX);
536
111
        let mut temp_guard = ScratchGuard::new(temp_path.clone());
537
111
        let 
size95
= match self.construct_directory(digest, &temp_path).await {
538
95
            Ok(size) => size,
539
13
            Err(e) => {
540
13
                temp_guard.disarm();
541
13
                Self::remove_tree_best_effort(&temp_path).await;
542
13
                return Err(e);
543
            }
544
        };
545
546
        // Publish. A pre-existing canonical dir can only be crash leftovers
547
        // from a previous process that the startup sweep has not yet
548
        // removed: live entries are tracked in the map (this digest has no
549
        // entry — we hold its construction lock and just re-checked), and
550
        // eviction renames trees to tombstones under the cache write lock
551
        // before deleting them.
552
95
        Self::remove_tree_best_effort(&cache_path).await;
553
95
        temp_guard.disarm();
554
95
        if let Err(
e0
) = fs::rename(&temp_path, &cache_path).await {
555
0
            Self::remove_tree_best_effort(&temp_path).await;
556
0
            return Err(e)
557
0
                .err_tip(|| format!("Failed to publish cache entry: {}", cache_path.display()));
558
95
        }
559
560
        // Insert into the cache. The in-memory map mutation and the
561
        // metadata-cheap tombstone renames of evicted victims run under the
562
        // write lock (see `tombstone_victims` for why the renames must);
563
        // the expensive filesystem deletion is dispatched off the lock so
564
        // eviction I/O never serializes other callers.
565
        //
566
        // The new entry is inserted pre-pinned. The hardlink-to-destination
567
        // below runs unlocked, and a concurrent caller for an unrelated
568
        // digest could otherwise pick this brand-new entry as an eviction
569
        // victim and delete its tree mid-hardlink. Dropping the pin releases
570
        // it once the hardlink is done.
571
95
        let ref_count = Arc::new(AtomicUsize::new(1));
572
95
        let pin = EntryPin {
573
95
            ref_count: Arc::clone(&ref_count),
574
95
        };
575
95
        let tombstones = {
576
95
            let mut cache = self.cache.write().await;
577
95
            let victims = self.evict_if_needed(size, &mut cache);
578
95
            let replaced = cache.insert(
579
95
                digest,
580
95
                CachedDirectoryMetadata {
581
95
                    path: cache_path.clone(),
582
95
                    size,
583
95
                    last_access: AtomicU64::new(unix_nanos_now()),
584
95
                    ref_count,
585
95
                },
586
95
            );
587
            // Maintain the lock-free metric mirrors. A replaced entry cannot
588
            // happen (we hold the construction lock and just re-checked), but
589
            // account for it defensively so the mirrors can never drift.
590
95
            if let Some(
old0
) = replaced {
591
0
                self.map_size_bytes.fetch_sub(old.size, Ordering::Relaxed);
592
95
            } else {
593
95
                self.map_entries.fetch_add(1, Ordering::Relaxed);
594
95
            }
595
95
            self.map_size_bytes.fetch_add(size, Ordering::Relaxed);
596
95
            self.tombstone_victims(victims).await
597
        };
598
95
        Self::dispatch_evictions(tombstones);
599
600
        // Hardlink to destination (unlocked). The entry is pinned so it
601
        // cannot be evicted from under this hardlink.
602
95
        let result = hardlink_directory_tree(&cache_path, dest_path).await;
603
95
        drop(pin);
604
95
        let method = result.err_tip(|| "Failed to hardlink newly cached directory")
?0
;
605
95
        self.record_clone_method(method);
606
607
95
        Ok((false, size))
608
143
    }
609
610
    /// Removes a cache entry whose on-disk tree failed to materialize
611
    /// (missing or corrupt), renames the tree to a tombstone, and deletes it
612
    /// in the background — the same mechanism eviction uses. Without this, a
613
    /// damaged entry would stay in the map and fail every future request for
614
    /// its digest until it happened to be evicted.
615
    ///
616
    /// `pin` is the guard returned by `acquire_entry` for the failed
617
    /// attempt: invalidation is skipped if the map now holds a DIFFERENT
618
    /// entry for this digest (single-flight already rebuilt it), identified
619
    /// by refcount-handle pointer identity.
620
3
    async fn invalidate_entry(&self, digest: &DigestInfo, pin: &EntryPin) {
621
3
        let tombstones = {
622
3
            let mut cache = self.cache.write().await;
623
3
            let is_same_entry = cache
624
3
                .get(digest)
625
3
                .is_some_and(|m| Arc::ptr_eq(&m.ref_count, &pin.ref_count));
626
3
            if !is_same_entry {
627
0
                return;
628
3
            }
629
3
            let Some(metadata) = cache.remove(digest) else {
630
0
                return;
631
            };
632
3
            self.map_entries.fetch_sub(1, Ordering::Relaxed);
633
3
            self.map_size_bytes
634
3
                .fetch_sub(metadata.size, Ordering::Relaxed);
635
3
            self.tombstone_victims(vec![metadata.path]).await
636
        };
637
3
        Self::dispatch_evictions(tombstones);
638
3
    }
639
640
    /// Best-effort `remove_dir_all` that treats `NotFound` as success and
641
    /// only warns on other failures. Used to clear partially populated
642
    /// destinations before a retry, partially built temp trees after a
643
    /// failed construction, and stale crash leftovers before publishing.
644
111
    async fn remove_tree_best_effort(path: &Path) {
645
111
        if let Err(
e103
) = fs::remove_dir_all(path).await
646
103
            && e.kind() != std::io::ErrorKind::NotFound
647
        {
648
0
            warn!(?path, error = ?e, "Failed to remove partial directory tree");
649
111
        }
650
111
    }
651
652
    /// Allocates a process-unique scratch path under `cache_root` (temp
653
    /// construction trees, eviction tombstones). Unique within the process
654
    /// via a monotonic counter; leftovers from previous processes are
655
    /// removed by the startup sweep in [`Self::new`].
656
141
    fn allocate_scratch_path(&self, prefix: &str) -> PathBuf {
657
141
        let seq = self.scratch_seq.fetch_add(1, Ordering::Relaxed);
658
141
        self.config.cache_root.join(format!("{prefix}{seq}"))
659
141
    }
660
661
    /// Renames each evicted tree to a unique tombstone path and returns the
662
    /// tombstones for background deletion.
663
    ///
664
    /// MUST be called while still holding the cache write lock that removed
665
    /// the victims from the map (or before the cache is shared, as in the
666
    /// startup sweep). That ordering is what makes eviction race-free
667
    /// against re-construction of an evicted digest: a constructor only
668
    /// constructs after a cache re-check, the re-check needs the read lock,
669
    /// so it is ordered after these renames — the background deletion can
670
    /// therefore never touch a canonical path a fresh construction publishes
671
    /// to. The rename itself is metadata-only and cheap; the expensive
672
    /// `remove_dir_all` still happens off-lock in the background.
673
98
    async fn tombstone_victims(&self, victims: Vec<PathBuf>) -> Vec<PathBuf> {
674
98
        let mut tombstones = Vec::with_capacity(victims.len());
675
98
        for 
path30
in victims {
676
30
            let tombstone = self.allocate_scratch_path(TOMBSTONE_PREFIX);
677
30
            match fs::rename(&path, &tombstone).await {
678
27
                Ok(()) => tombstones.push(tombstone),
679
3
                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
680
3
                    // Already gone from disk; nothing to delete.
681
3
                }
682
0
                Err(e) => {
683
0
                    warn!(
684
                        ?path,
685
                        error = ?e,
686
                        "Failed to tombstone evicted directory, deleting in place"
687
                    );
688
0
                    tombstones.push(path);
689
                }
690
            }
691
        }
692
98
        tombstones
693
98
    }
694
695
    /// If `digest` is cached, pins it against eviction and returns a
696
    /// snapshot of its on-disk path, its recorded size, and the RAII pin.
697
    /// Runs under the cache READ lock — the refcount and LRU timestamp are
698
    /// atomics, so hits do not serialize on the write lock. The increment is
699
    /// race-free against eviction because eviction requires the write lock,
700
    /// which excludes every read-lock holder: an entry with an outstanding
701
    /// pin is always observed with a non-zero count by `evict_lru`.
702
302
    async fn acquire_entry(&self, digest: &DigestInfo) -> Option<(PathBuf, u64, EntryPin)> {
703
302
        let cache = self.cache.read().await;
704
302
        let 
metadata48
= cache.get(digest)
?254
;
705
48
        metadata
706
48
            .last_access
707
48
            .store(unix_nanos_now(), Ordering::Relaxed);
708
48
        metadata.ref_count.fetch_add(1, Ordering::SeqCst);
709
48
        Some((
710
48
            metadata.path.clone(),
711
48
            metadata.size,
712
48
            EntryPin {
713
48
                ref_count: Arc::clone(&metadata.ref_count),
714
48
            },
715
48
        ))
716
302
    }
717
718
    /// Drops the per-digest construction mutex from the stampede map once
719
    /// construction (or the post-construction recheck) for `digest` is done.
720
    /// Without this the map grows unbounded over the worker's lifetime.
721
    ///
722
    /// Safe to call while holding the construction guard: a concurrent waiter
723
    /// already cloned the `Arc<Mutex>` before blocking, so removing the map
724
    /// entry only prevents *future* callers from joining this exact mutex —
725
    /// they will create a fresh one, re-check the cache, find the entry, and
726
    /// take the fast hardlink path. It never causes a redundant construct.
727
143
    async fn forget_construction_lock(&self, digest: &DigestInfo) {
728
143
        self.construction_locks.lock().await.remove(digest);
729
143
    }
730
731
    /// Constructs a directory from the CAS at the given path and returns the
732
    /// total size of the materialized tree in bytes.
733
    ///
734
    /// The size is accumulated from `FileNode.digest.size_bytes` in the
735
    /// `Directory` protos as the tree is built, rather than walking the
736
    /// filesystem afterwards with `fs::metadata` per file. Symlinks contribute
737
    /// nothing — a symlink's own inode is negligible and following it could
738
    /// double-count a file already counted via its `FileNode`.
739
    ///
740
    /// Each directory's final mode (0o755) is set at creation time, so no
741
    /// separate recursive permission pass is needed after construction.
742
228
    fn construct_directory<'a>(
743
228
        &'a self,
744
228
        digest: DigestInfo,
745
228
        dest_path: &'a Path,
746
228
    ) -> Pin<Box<dyn Future<Output = Result<u64, Error>> + Send + 'a>> {
747
228
        Box::pin(async move {
748
228
            debug!(?digest, ?dest_path, "Constructing directory");
749
750
            // Fetch the Directory proto (permit held only for the fetch).
751
220
            let directory: ProtoDirectory = {
752
228
                let _permit = self.acquire_fetch_permit().await
?0
;
753
228
                get_and_decode_digest(self.cas_store.as_ref(), digest.into())
754
228
                    .await
755
225
                    .err_tip(|| 
format!5
("Failed to fetch directory digest: {digest:?}"))
?5
756
            };
757
758
            // Create the destination directory. It must be writable while it
759
            // is being populated; 0o755 is its final mode too, so set it now
760
            // (umask-independent) — no post-construction permission walk.
761
220
            self.create_dir_writable(dest_path).await
?0
;
762
763
220
            let mut total_size: u64 = 0;
764
1.60k
            for file in 
&directory.files220
{
765
1.60k
                if let Some(file_digest) = &file.digest {
766
1.60k
                    // size_bytes is non-negative; clamp defensively.
767
1.60k
                    total_size += u64::try_from(file_digest.size_bytes).unwrap_or(0);
768
1.60k
                
}0
769
            }
770
771
            // Materialize all nodes of this directory level concurrently,
772
            // sharing one concurrency budget — the same shape as
773
            // `download_to_directory`. Every file that is not in the fast
774
            // tier pays a full slow-store round trip, so awaiting nodes
775
            // sequentially serializes N round trips and dominates cold-cache
776
            // construction time for large trees. Futures yield the subtree
777
            // size they materialized (0 for files, whose sizes are already
778
            // summed from their digests above, and for symlinks).
779
220
            let mut node_futures: Vec<BoxFuture<'_, Result<u64, Error>>> = Vec::with_capacity(
780
220
                directory.files.len() + directory.directories.len() + directory.symlinks.len(),
781
            );
782
1.60k
            for file in 
&directory.files220
{
783
1.60k
                node_futures.push(Box::pin(async move {
784
1.60k
                    self.create_file(dest_path, file).await.map(|()| 0)
785
1.60k
                }));
786
            }
787
220
            for 
dir_node194
in &directory.directories {
788
194
                node_futures.push(Box::pin(self.create_subdirectory(dest_path, dir_node)));
789
194
            }
790
220
            for 
symlink125
in &directory.symlinks {
791
125
                node_futures.push(Box::pin(async move {
792
125
                    self.create_symlink(dest_path, symlink).await.map(|()| 0)
793
125
                }));
794
            }
795
220
            total_size += futures::stream::iter(node_futures)
796
220
                .buffer_unordered(CONSTRUCT_DIRECTORY_CONCURRENCY)
797
3.82k
                .
try_fold220
(0u64, |acc, size| async move
{1.91k
Ok(acc + size)1.91k
})
798
220
                .await
?8
;
799
800
212
            Ok(total_size)
801
225
        })
802
228
    }
803
804
    /// Creates `dir` (and any missing parents) and sets its mode to 0o755 so
805
    /// that it is writable while the cache entry is being populated and stays
806
    /// at a stable, umask-independent final mode afterwards.
807
220
    async fn create_dir_writable(&self, dir: &Path) -> Result<(), Error> {
808
220
        fs::create_dir_all(dir)
809
220
            .await
810
220
            .err_tip(|| 
format!0
("Failed to create directory: {}",
dir0
.
display0
()))
?0
;
811
        #[cfg(unix)]
812
        {
813
            use std::os::unix::fs::PermissionsExt;
814
220
            fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755))
815
220
                .await
816
220
                .err_tip(|| 
format!0
("Failed to set directory mode: {}",
dir0
.
display0
()))
?0
;
817
        }
818
220
        Ok(())
819
220
    }
820
821
    /// Creates a file from a `FileNode` inside a cache entry.
822
    ///
823
    /// The fast path hardlinks the `FilesystemStore` CAS blob directly into the
824
    /// cache entry — zero-copy, metadata-only — exactly like
825
    /// `download_to_directory`. A hardlinked file shares its inode with the CAS
826
    /// store (and every other action that hardlinked the same blob), so it MUST
827
    /// NOT be chmod'd: doing so is the inode-corruption bug PR #2347 fixed.
828
    ///
829
    /// This imposes two correctness rules, both handled here:
830
    ///  * Executable files (`FileNode.is_executable`) need the `+x` bit, which
831
    ///    cannot be applied to a shared CAS inode. They are given their own
832
    ///    private inode via fetch+write and then chmod'd — never hardlinked.
833
    ///  * If the blob is not locally hardlinkable (the fast tier is not a
834
    ///    `FilesystemStore`, or the blob is not present in it / was evicted),
835
    ///    fall back to fetch+write for that file rather than failing.
836
1.60k
    async fn create_file(&self, parent: &Path, file_node: &FileNode) -> Result<(), Error> {
837
1.60k
        let file_path = parent.join(&file_node.name);
838
1.60k
        let digest = DigestInfo::try_from(
839
1.60k
            file_node
840
1.60k
                .digest
841
1.60k
                .clone()
842
1.60k
                .ok_or_else(|| 
make_err!0
(
Code::InvalidArgument0
, "File node missing digest"))
?0
,
843
        )
844
1.60k
        .err_tip(|| "Invalid file digest")
?0
;
845
846
1.60k
        trace!(?file_path, ?digest, "Creating file");
847
848
        // Zero-byte files (digest af1349b9...-0) are not stored in
849
        // FilesystemStore / many CAS backends, so fetching here returns
850
        // NotFound. In Bazel-style trees these show up frequently as empty
851
        // marker / config files (.linksearchpaths, empty .env, .toml, etc.),
852
        // and a single failure aborts the whole DirectoryCache construction.
853
        // Short-circuit and write the empty file directly.
854
1.60k
        if is_zero_digest(digest) {
855
188
            fs::write(&file_path, b"")
856
188
                .await
857
188
                .err_tip(|| 
format!0
("Failed to write empty file: {}",
file_path.display()0
))
?0
;
858
188
            return Ok(());
859
1.41k
        }
860
861
        // Executable files need their own inode to carry the +x bit without
862
        // mutating the shared CAS blob — copy, never hardlink.
863
1.41k
        if file_node.is_executable {
864
188
            return self.copy_file_to(&digest, &file_path, true).await;
865
1.22k
        }
866
867
        // Non-executable file: try to hardlink the CAS blob directly.
868
1.22k
        if let Some(filesystem_store) = &self.filesystem_store {
869
1.22k
            match self
870
1.22k
                .hardlink_cas_blob(filesystem_store, &digest, &file_path)
871
1.22k
                .await
872
            {
873
1.22k
                Ok(()) => return Ok(()),
874
0
                Err(e) if e.code == Code::NotFound => {
875
                    // The blob is not in the filesystem tier (e.g. it lives
876
                    // only in the slow store, or was evicted). Fall through
877
                    // to fetch+write rather than failing the whole build.
878
0
                    trace!(
879
                        ?digest,
880
                        ?file_path,
881
                        "CAS blob not locally hardlinkable, copying instead"
882
                    );
883
                }
884
0
                Err(e) => return Err(e),
885
            }
886
0
        }
887
888
        // Fallback: fetch the blob and write a private copy. Non-executable,
889
        // but still made read-only (0o444) by copy_file_to so the materialized
890
        // input is immutable like the hardlink path.
891
0
        self.copy_file_to(&digest, &file_path, false).await
892
1.60k
    }
893
894
    /// Hardlinks the `FilesystemStore` CAS blob for `digest` into `file_path`.
895
    /// Mirrors `download_to_directory`: populate the fast store, resolve the
896
    /// blob's on-disk path under the entry lock, then `fs::hard_link`.
897
    ///
898
    /// Returns a `NotFound` error if the blob is not present in the filesystem
899
    /// tier; callers fall back to fetch+write in that case.
900
1.22k
    async fn hardlink_cas_blob(
901
1.22k
        &self,
902
1.22k
        filesystem_store: &FilesystemStore,
903
1.22k
        digest: &DigestInfo,
904
1.22k
        file_path: &Path,
905
1.22k
    ) -> Result<(), Error> {
906
        // Ensure the blob is in the fast (filesystem) tier so it has an
907
        // on-disk file we can hardlink. This may fetch from the slow store,
908
        // so it holds a fetch permit.
909
        {
910
1.22k
            let _permit = self.acquire_fetch_permit().await
?0
;
911
1.22k
            self.cas_store
912
1.22k
                .populate_fast_store(StoreKey::Digest(*digest))
913
1.22k
                .await
914
1.22k
                .err_tip(|| 
format!0
("Failed to populate fast store for {digest}"))
?0
;
915
        }
916
917
1.22k
        let file_entry = filesystem_store
918
1.22k
            .get_file_entry_for_digest(digest)
919
1.22k
            .await
920
1.22k
            .err_tip(|| "Resolving CAS file entry for hardlink")
?0
;
921
922
1.22k
        let file_path = file_path.to_path_buf();
923
1.22k
        file_entry
924
1.22k
            .get_file_path_locked(move |src| async move {
925
1.22k
                fs::hard_link(&src, &file_path).await.err_tip(|| 
{0
926
0
                    format!(
927
                        "Failed to hardlink CAS blob into cache entry: {}",
928
0
                        file_path.display()
929
                    )
930
0
                })
931
2.45k
            })
932
1.22k
            .await
933
1.22k
    }
934
935
    /// Fetches the blob for `digest` from the CAS and writes a private copy at
936
    /// `file_path`, then chmods it read-only (`0o555` when `executable`, else
937
    /// `0o444`). This is safe because the copy has its own inode, unshared with
938
    /// the CAS, so the chmod cannot mutate a shared blob.
939
188
    async fn copy_file_to(
940
188
        &self,
941
188
        digest: &DigestInfo,
942
188
        file_path: &Path,
943
188
        executable: bool,
944
188
    ) -> Result<(), Error> {
945
188
        let data = {
946
188
            let _permit = self.acquire_fetch_permit().await
?0
;
947
188
            self.cas_store
948
188
                .get_part_unchunked(StoreKey::Digest(*digest), 0, None)
949
188
                .await
950
188
                .err_tip(|| 
format!0
("Failed to fetch file: {}",
file_path0
.
display0
()))
?0
951
        };
952
953
188
        fs::write(file_path, data.as_ref())
954
188
            .await
955
188
            .err_tip(|| 
format!0
("Failed to write file: {}",
file_path0
.
display0
()))
?0
;
956
957
        #[cfg(unix)]
958
        {
959
            use std::os::unix::fs::PermissionsExt;
960
            // Read-only, matching the hermeticity contract for materialized
961
            // inputs: 0o555 (r-xr-xr-x) for executables so the +x bit survives,
962
            // 0o444 (r--r--r--) for data files. This file has its own private
963
            // inode (just written above), so chmoding it cannot affect any CAS
964
            // blob or another action's hardlink — unlike the hardlink path,
965
            // where the shared read-only blob must never be chmod'd.
966
188
            let mode = if executable { 0o555 } else { 
0o4440
};
967
188
            fs::set_permissions(file_path, std::fs::Permissions::from_mode(mode))
968
188
                .await
969
188
                .err_tip(|| 
format!0
("Failed to set permissions: {}",
file_path0
.
display0
()))
?0
;
970
        }
971
        #[cfg(not(unix))]
972
        let _ = executable;
973
974
188
        Ok(())
975
188
    }
976
977
    /// Creates a subdirectory from a `DirectoryNode`, returning the total size
978
    /// of the subtree it materializes.
979
    ///
980
    /// With `experimental_subtree_caching` enabled, the subtree is looked up
981
    /// in (and inserted into) the cache by its own `Directory` digest, so a
982
    /// subtree already constructed under any previous root is one
983
    /// `hardlink_directory_tree` call instead of a full recursive rebuild.
984
    /// When disabled (the default), the subtree is always constructed
985
    /// directly under the parent with no cache interaction — today's
986
    /// behavior, unchanged.
987
194
    async fn create_subdirectory(
988
194
        &self,
989
194
        parent: &Path,
990
194
        dir_node: &DirectoryNode,
991
194
    ) -> Result<u64, Error> {
992
194
        let dir_path = parent.join(&dir_node.name);
993
194
        let digest =
994
194
            DigestInfo::try_from(dir_node.digest.clone().ok_or_else(|| 
{0
995
0
                make_err!(Code::InvalidArgument, "Directory node missing digest")
996
0
            })?)
997
194
            .err_tip(|| "Invalid directory digest")
?0
;
998
999
194
        trace!(?dir_path, ?digest, "Creating subdirectory");
1000
1001
194
        if self.config.experimental_subtree_caching {
1002
            // Subtree caching: look the subtree up in (and insert it into)
1003
            // the cache by its own digest through the same core flow root
1004
            // materializations use. REAPI `Directory` nodes are
1005
            // content-addressed, so a subtree digest seen under one root is
1006
            // byte-identical wherever else it appears; construction recurses
1007
            // through this method, so nested subtrees get their own entries
1008
            // too (leaf-level reuse).
1009
77
            let (
hit66
,
_size66
) = self.get_or_create_entry(digest, &dir_path).await
?8
;
1010
66
            let counter = if hit {
1011
6
                &self.subtree_hits
1012
            } else {
1013
60
                &self.subtree_misses
1014
            };
1015
66
            counter.fetch_add(1, Ordering::Relaxed);
1016
            // The child's cache entry owns its bytes: contribute 0 to the
1017
            // parent's recorded size, so an entry's size covers only bytes
1018
            // not owned by a descendant entry and the sum over the map
1019
            // approximates unique materialized bytes. Counting the real
1020
            // subtree size here instead would tally every file once per
1021
            // ancestor level (~depth-fold inflation of `max_size_bytes`
1022
            // pressure) and make the cache self-defeat via phantom
1023
            // evictions.
1024
66
            return Ok(0);
1025
117
        }
1026
1027
        // Recursively construct subdirectory
1028
117
        self.construct_directory(digest, &dir_path).await
1029
191
    }
1030
1031
    /// Creates a symlink from a `SymlinkNode`
1032
125
    async fn create_symlink(&self, parent: &Path, symlink: &SymlinkNode) -> Result<(), Error> {
1033
125
        let link_path = parent.join(&symlink.name);
1034
125
        let target = Path::new(&symlink.target);
1035
1036
125
        trace!(?link_path, ?target, "Creating symlink");
1037
1038
        #[cfg(unix)]
1039
125
        fs::symlink(&target, &link_path)
1040
125
            .await
1041
125
            .err_tip(|| 
format!0
("Failed to create symlink: {}",
link_path.display()0
))
?0
;
1042
1043
        #[cfg(windows)]
1044
        {
1045
            // On Windows, we need to know if target is a directory
1046
            // For now, assume files (can be improved later)
1047
            fs::symlink_file(&target, &link_path)
1048
                .await
1049
                .err_tip(|| format!("Failed to create symlink: {}", link_path.display()))?;
1050
        }
1051
1052
125
        Ok(())
1053
125
    }
1054
1055
    /// Selects and removes victim entries from the in-memory `cache` map until
1056
    /// it is within the entry-count and size budgets, and returns the on-disk
1057
    /// paths of the removed entries.
1058
    ///
1059
    /// This is a pure in-memory operation — it does NO filesystem I/O and is
1060
    /// not `async`. The caller runs it under the cache write lock and then,
1061
    /// after releasing the lock, dispatches the returned paths for deletion
1062
    /// via [`Self::dispatch_evictions`]. Keeping eviction's `remove_dir_all`
1063
    /// off the write lock prevents one caller's eviction I/O from serializing
1064
    /// every other concurrent `get_or_create`.
1065
95
    fn evict_if_needed(
1066
95
        &self,
1067
95
        incoming_size: u64,
1068
95
        cache: &mut HashMap<DigestInfo, CachedDirectoryMetadata>,
1069
95
    ) -> Vec<PathBuf> {
1070
95
        let mut evicted_paths = Vec::new();
1071
1072
        // Check entry count
1073
122
        while cache.len() >= self.config.max_entries {
1074
55
            let Some((
e_size27
,
path27
)) = Self::evict_lru(cache) else {
1075
                // nothing evictable (all entries pinned) — have to exit
1076
28
                warn!(
1077
28
                    current_items = cache.len(),
1078
                    max_entries = self.config.max_entries,
1079
                    "Unable to evict anything from directory_cache, will exceed max entries"
1080
                );
1081
28
                break;
1082
            };
1083
27
            self.evictions.fetch_add(1, Ordering::Relaxed);
1084
27
            self.map_entries.fetch_sub(1, Ordering::Relaxed);
1085
27
            self.map_size_bytes.fetch_sub(e_size, Ordering::Relaxed);
1086
27
            evicted_paths.push(path);
1087
        }
1088
1089
        // Check total size
1090
95
        if self.config.max_size_bytes > 0 {
1091
95
            let current_size: u64 = cache.values().map(|m| m.size).sum();
1092
95
            let mut size_after = current_size + incoming_size;
1093
1094
95
            while size_after > self.config.max_size_bytes {
1095
0
                let Some((e_size, path)) = Self::evict_lru(cache) else {
1096
                    // nothing evictable (all entries pinned) — have to exit
1097
0
                    warn!(
1098
                        size_after,
1099
                        max_size_bytes = self.config.max_size_bytes,
1100
                        "Unable to evict anything from directory_cache, will exceed max size"
1101
                    );
1102
0
                    break;
1103
                };
1104
0
                self.evictions.fetch_add(1, Ordering::Relaxed);
1105
0
                self.map_entries.fetch_sub(1, Ordering::Relaxed);
1106
0
                self.map_size_bytes.fetch_sub(e_size, Ordering::Relaxed);
1107
0
                size_after -= e_size;
1108
0
                evicted_paths.push(path);
1109
            }
1110
0
        }
1111
1112
95
        evicted_paths
1113
95
    }
1114
1115
    /// Removes the least-recently-used unpinned entry from the in-memory map
1116
    /// and returns its `(size, path)`. Entries with `ref_count > 0` are
1117
    /// in-flight materializations and are never selected — their on-disk tree
1118
    /// must not be deleted while a caller is hardlinking from it.
1119
    ///
1120
    /// Pure in-memory; the actual filesystem deletion is the caller's job.
1121
55
    fn evict_lru(
1122
55
        cache: &mut HashMap<DigestInfo, CachedDirectoryMetadata>,
1123
55
    ) -> Option<(u64, PathBuf)> {
1124
55
        let 
to_evict27
= cache
1125
55
            .iter()
1126
380
            .
filter55
(|(_, m)| m.ref_count.load(Ordering::SeqCst) == 0)
1127
55
            .min_by_key(|(_, m)| 
m.last_access52
.
load52
(
Ordering::Relaxed52
))
1128
55
            .map(|(digest, _)| *digest)
?28
;
1129
27
        let metadata = cache.remove(&to_evict)
?0
;
1130
27
        debug!(
1131
            digest = ?to_evict,
1132
            size = metadata.size,
1133
            "Evicting cached directory"
1134
        );
1135
27
        Some((metadata.size, metadata.path))
1136
55
    }
1137
1138
    /// Dispatches filesystem deletion of evicted cache-entry trees onto a
1139
    /// background task, so eviction I/O never runs under the cache write lock.
1140
    ///
1141
    /// Each tree's directories are chmod'd writable first
1142
    /// (`set_dir_writable_recursive`) — never its files: a cache-entry file
1143
    /// shares an inode with the `FilesystemStore` CAS blob and every action
1144
    /// that hardlinked it, so chmoding it would corrupt that shared inode (the
1145
    /// PR #2347 bug). Directory write permission alone is sufficient to unlink
1146
    /// files on unix.
1147
101
    fn dispatch_evictions(paths: Vec<PathBuf>) {
1148
101
        if paths.is_empty() {
1149
84
            return;
1150
17
        }
1151
17
        background_spawn!("directory_cache_evict", async move {
1152
30
            for path in 
paths17
{
1153
30
                match fs::metadata(&path).await {
1154
                    Err(_) => {
1155
                        // Already gone (e.g. a prior cleanup, or the cache
1156
                        // root was torn down). Nothing to do, not an error.
1157
3
                        continue;
1158
                    }
1159
27
                    Ok(
metadata0
) if !metadata.is_dir(
)0
=> {
1160
                        // Stray file (only plausible from an orphan sweep of
1161
                        // a polluted cache root).
1162
0
                        if let Err(e) = fs::remove_file(&path).await {
1163
0
                            warn!(?path, error = ?e, "Failed to remove evicted file from disk");
1164
0
                        }
1165
0
                        continue;
1166
                    }
1167
27
                    Ok(_) => {}
1168
                }
1169
27
                if let Err(
e0
) = set_dir_writable_recursive(&path).await {
1170
0
                    warn!(
1171
                        ?path,
1172
                        error = ?e,
1173
                        "Unable to mark evicted directory writable, removal may fail"
1174
                    );
1175
26
                }
1176
26
                if let Err(
e0
) = fs::remove_dir_all(&path).await {
1177
0
                    warn!(
1178
                        ?path,
1179
                        error = ?e,
1180
                        "Failed to remove evicted directory from disk"
1181
                    );
1182
25
                }
1183
            }
1184
15
        });
1185
101
    }
1186
1187
    /// Gets the cache path for a digest
1188
114
    fn get_cache_path(&self, digest: &DigestInfo) -> PathBuf {
1189
114
        self.config.cache_root.join(format!("{digest}"))
1190
114
    }
1191
1192
    /// Returns cache statistics
1193
11
    
pub async fn stats(&self) -> CacheStats3
{
1194
11
        let cache = self.cache.read().await;
1195
11
        let total_size: u64 = cache.values().map(|m| m.size).sum();
1196
11
        let in_use = cache
1197
11
            .values()
1198
61
            .
filter11
(|m| m.ref_count.load(Ordering::SeqCst) > 0)
1199
11
            .count();
1200
1201
11
        CacheStats {
1202
11
            entries: cache.len(),
1203
11
            total_size_bytes: total_size,
1204
11
            in_use_entries: in_use,
1205
11
            clonefile_hits: self.clonefile_hits.load(Ordering::Relaxed),
1206
11
            hardlink_hits: self.hardlink_hits.load(Ordering::Relaxed),
1207
11
            subtree_hits: self.subtree_hits.load(Ordering::Relaxed),
1208
11
            subtree_misses: self.subtree_misses.load(Ordering::Relaxed),
1209
11
            evictions: self.evictions.load(Ordering::Relaxed),
1210
11
        }
1211
11
    }
1212
}
1213
1214
impl MetricsComponent for DirectoryCache {
1215
0
    fn publish(
1216
0
        &self,
1217
0
        _kind: MetricKind,
1218
0
        field_metadata: MetricFieldData,
1219
0
    ) -> Result<MetricPublishKnownKindData, nativelink_metric::Error> {
1220
0
        let _enter = group!(field_metadata.name).entered();
1221
0
        publish!(
1222
0
            "clonefile_hits",
1223
0
            &self.clonefile_hits,
1224
0
            MetricKind::Counter,
1225
0
            "Materializations that used APFS clonefile(2) (macOS only)"
1226
        );
1227
0
        publish!(
1228
0
            "hardlink_hits",
1229
0
            &self.hardlink_hits,
1230
0
            MetricKind::Counter,
1231
0
            "Materializations that used per-file hardlinks"
1232
        );
1233
0
        publish!(
1234
0
            "subtree_hits",
1235
0
            &self.subtree_hits,
1236
0
            MetricKind::Counter,
1237
0
            "Subtree materializations served from the cache (experimental_subtree_caching)"
1238
        );
1239
0
        publish!(
1240
0
            "subtree_misses",
1241
0
            &self.subtree_misses,
1242
0
            MetricKind::Counter,
1243
0
            "Subtree materializations that had to be constructed (experimental_subtree_caching)"
1244
        );
1245
0
        publish!(
1246
0
            "evictions",
1247
0
            &self.evictions,
1248
0
            MetricKind::Counter,
1249
0
            "Cache entries removed by LRU eviction (high rate = cache thrash)"
1250
        );
1251
0
        publish!(
1252
0
            "entries",
1253
0
            &self.map_entries,
1254
0
            MetricKind::Default,
1255
0
            "Current number of cached directory entries"
1256
        );
1257
0
        publish!(
1258
0
            "total_size_bytes",
1259
0
            &self.map_size_bytes,
1260
0
            MetricKind::Default,
1261
0
            "Current recorded size of all cached entries in bytes"
1262
        );
1263
0
        Ok(MetricPublishKnownKindData::Component)
1264
0
    }
1265
}
1266
1267
/// Statistics about the directory cache
1268
#[derive(Debug, Clone, Copy)]
1269
pub struct CacheStats {
1270
    pub entries: usize,
1271
    pub total_size_bytes: u64,
1272
    pub in_use_entries: usize,
1273
    /// Materializations that used APFS `clonefile(2)` (macOS).
1274
    pub clonefile_hits: u64,
1275
    /// Materializations that used per-file `fs::hard_link`.
1276
    pub hardlink_hits: u64,
1277
    /// Subtree materializations served from the cache by digest. Always zero
1278
    /// unless `experimental_subtree_caching` is enabled.
1279
    pub subtree_hits: u64,
1280
    /// Subtree constructions not servable from the cache. Always zero unless
1281
    /// `experimental_subtree_caching` is enabled.
1282
    pub subtree_misses: u64,
1283
    /// Entries removed by LRU eviction. A high rate relative to hits means
1284
    /// the cache is thrashing and `max_entries`/`max_size_bytes` should be
1285
    /// raised.
1286
    pub evictions: u64,
1287
}
1288
1289
#[cfg(test)]
1290
mod tests {
1291
    use nativelink_config::stores::{
1292
        FastSlowSpec, FilesystemSpec, MemorySpec, StoreDirection, StoreSpec,
1293
    };
1294
    use nativelink_macro::nativelink_test;
1295
    use nativelink_store::memory_store::MemoryStore;
1296
    use nativelink_util::store_trait::Store;
1297
    use prost::Message;
1298
    use tempfile::TempDir;
1299
1300
    use super::*;
1301
1302
    /// Builds a `FastSlowStore` whose fast tier is a real `FilesystemStore`
1303
    /// and whose slow tier is a `MemoryStore` — the same shape the worker
1304
    /// wires up. Returns the `FastSlowStore` plus the slow `Store` handle so
1305
    /// tests can seed blobs/protos into the slow tier.
1306
9
    async fn make_fast_slow_store(temp_dir: &TempDir) -> (Arc<FastSlowStore>, Store) {
1307
9
        let fast_spec = FilesystemSpec {
1308
9
            content_path: temp_dir
1309
9
                .path()
1310
9
                .join("cas_content")
1311
9
                .to_string_lossy()
1312
9
                .into_owned(),
1313
9
            temp_path: temp_dir
1314
9
                .path()
1315
9
                .join("cas_temp")
1316
9
                .to_string_lossy()
1317
9
                .into_owned(),
1318
9
            eviction_policy: None,
1319
9
            ..Default::default()
1320
9
        };
1321
9
        let slow_spec = MemorySpec::default();
1322
9
        let fast_store: Arc<FilesystemStore> = FilesystemStore::new(&fast_spec).await.unwrap();
1323
9
        let slow_store = MemoryStore::new(&slow_spec);
1324
9
        let cas_store = FastSlowStore::new(
1325
9
            &FastSlowSpec {
1326
9
                fast: StoreSpec::Filesystem(fast_spec),
1327
9
                slow: StoreSpec::Memory(slow_spec),
1328
9
                fast_direction: StoreDirection::default(),
1329
9
                slow_direction: StoreDirection::default(),
1330
9
                bypass_dedup_threshold_bytes: 0,
1331
9
            },
1332
9
            Store::new(fast_store),
1333
9
            Store::new(slow_store.clone()),
1334
        );
1335
9
        (cas_store, Store::new(slow_store))
1336
9
    }
1337
1338
    /// Uploads `content` to `store` under a digest derived from `tag`, returns
1339
    /// the digest. `FastSlowStore`/`MemoryStore`/`FilesystemStore` do not
1340
    /// verify content hashes, so a synthetic-but-unique digest is sufficient.
1341
22
    async fn upload_blob(store: &Store, tag: u8, content: &[u8]) -> DigestInfo {
1342
22
        let digest = DigestInfo::new([tag; 32], content.len() as u64);
1343
22
        store
1344
22
            .as_store_driver_pin()
1345
22
            .update_oneshot(digest.into(), content.to_vec().into())
1346
22
            .await
1347
22
            .unwrap();
1348
22
        digest
1349
22
    }
1350
1351
    /// Seeds a one-file directory ("test.txt" = "Hello, World!") into the slow
1352
    /// store and returns the `FastSlowStore` + the root directory digest.
1353
4
    async fn setup_test_store(temp_dir: &TempDir) -> (Arc<FastSlowStore>, DigestInfo) {
1354
4
        let (cas_store, slow_store) = make_fast_slow_store(temp_dir).await;
1355
1356
4
        let file_digest = upload_blob(&slow_store, 1, b"Hello, World!").await;
1357
1358
4
        let directory = ProtoDirectory {
1359
4
            files: vec![FileNode {
1360
4
                name: "test.txt".to_string(),
1361
4
                digest: Some(file_digest.into()),
1362
4
                is_executable: false,
1363
4
                ..Default::default()
1364
4
            }],
1365
4
            directories: vec![],
1366
4
            symlinks: vec![],
1367
4
            ..Default::default()
1368
4
        };
1369
4
        let mut dir_data = Vec::new();
1370
4
        directory.encode(&mut dir_data).unwrap();
1371
4
        let dir_digest = upload_blob(&slow_store, 2, &dir_data).await;
1372
1373
4
        (cas_store, dir_digest)
1374
4
    }
1375
1376
    #[nativelink_test]
1377
    async fn test_directory_cache_basic() -> Result<(), Error> {
1378
        let temp_dir = TempDir::new().unwrap();
1379
        let cache_root = temp_dir.path().join("cache");
1380
        let (store, dir_digest) = setup_test_store(&temp_dir).await;
1381
1382
        let config = DirectoryCacheConfig {
1383
            max_entries: 10,
1384
            max_size_bytes: 1024 * 1024,
1385
            cache_root,
1386
            ..Default::default()
1387
        };
1388
1389
        let cache = DirectoryCache::new(config, store).await?;
1390
1391
        // First access - cache miss
1392
        let dest1 = temp_dir.path().join("dest1");
1393
        let hit = cache.get_or_create(dir_digest, &dest1).await?;
1394
        assert!(!hit, "First access should be cache miss");
1395
        assert!(dest1.join("test.txt").exists());
1396
        assert_eq!(
1397
            fs::read(dest1.join("test.txt")).await.unwrap(),
1398
            b"Hello, World!",
1399
            "materialized file content must be byte-identical to the CAS blob"
1400
        );
1401
1402
        // Second access - cache hit
1403
        let dest2 = temp_dir.path().join("dest2");
1404
        let hit = cache.get_or_create(dir_digest, &dest2).await?;
1405
        assert!(hit, "Second access should be cache hit");
1406
        assert!(dest2.join("test.txt").exists());
1407
        assert_eq!(
1408
            fs::read(dest2.join("test.txt")).await.unwrap(),
1409
            b"Hello, World!",
1410
            "cache-hit materialized content must be byte-identical"
1411
        );
1412
1413
        // Verify stats
1414
        let stats = cache.stats().await;
1415
        assert_eq!(stats.entries, 1);
1416
1417
        // Two get_or_create calls succeeded → two materializations were
1418
        // recorded. On macOS both should be clonefile; on Linux both hardlink.
1419
        #[cfg(target_os = "macos")]
1420
        {
1421
            assert_eq!(stats.clonefile_hits, 2, "macOS should record 2 clones");
1422
            assert_eq!(stats.hardlink_hits, 0);
1423
        }
1424
        #[cfg(not(target_os = "macos"))]
1425
        {
1426
            assert_eq!(stats.clonefile_hits, 0);
1427
            assert_eq!(
1428
                stats.hardlink_hits, 2,
1429
                "non-macOS should record 2 hardlinks"
1430
            );
1431
        }
1432
1433
        Ok(())
1434
    }
1435
1436
    /// A Directory containing a zero-byte file must be constructible even when
1437
    /// the CAS has no entry for the zero-byte digest. In production CAS
1438
    /// backends (`FilesystemStore` in particular) refuse to store zero-byte
1439
    /// blobs, so without the short-circuit this is a `NotFound` error and 30%+
1440
    /// of cache constructions fail (per PR #2243).
1441
    #[nativelink_test]
1442
    async fn test_directory_cache_zero_byte_file() -> Result<(), Error> {
1443
        let temp_dir = TempDir::new().unwrap();
1444
        let cache_root = temp_dir.path().join("cache");
1445
        let (store, slow_store) = make_fast_slow_store(&temp_dir).await;
1446
1447
        // RFC 6234 / Bazel zero-byte SHA-256 digest, hash for b"".
1448
        let zero_digest = DigestInfo::try_new(
1449
            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
1450
            0,
1451
        )
1452
        .unwrap();
1453
        // Deliberately do NOT upload the zero-byte blob — that's the whole
1454
        // point: real CAS backends won't have it.
1455
1456
        let directory = ProtoDirectory {
1457
            files: vec![FileNode {
1458
                name: "empty.txt".to_string(),
1459
                digest: Some(zero_digest.into()),
1460
                is_executable: false,
1461
                ..Default::default()
1462
            }],
1463
            directories: vec![],
1464
            symlinks: vec![],
1465
            ..Default::default()
1466
        };
1467
        let mut dir_data = Vec::new();
1468
        directory.encode(&mut dir_data).unwrap();
1469
        let dir_digest = upload_blob(&slow_store, 3, &dir_data).await;
1470
1471
        let config = DirectoryCacheConfig {
1472
            max_entries: 10,
1473
            max_size_bytes: 1024 * 1024,
1474
            cache_root,
1475
            ..Default::default()
1476
        };
1477
        let cache = DirectoryCache::new(config, store).await?;
1478
1479
        let dest = temp_dir.path().join("dest_empty");
1480
        let hit = cache.get_or_create(dir_digest, &dest).await?;
1481
        assert!(!hit, "First construction should be a cache miss");
1482
1483
        let empty_path = dest.join("empty.txt");
1484
        assert!(empty_path.exists(), "zero-byte file should be created");
1485
        let metadata = fs::metadata(&empty_path).await.unwrap();
1486
        assert_eq!(metadata.len(), 0, "zero-byte file must be 0 bytes");
1487
1488
        Ok(())
1489
    }
1490
1491
    /// Regression test for CAS inode corruption during directory cache
1492
    /// eviction.
1493
    ///
1494
    /// Background: a cached directory's files share an inode (via hardlink)
1495
    /// with both the `FilesystemStore` CAS entry and every action workspace
1496
    /// that has consumed this cached directory. The cleanup path that runs
1497
    /// before `fs::remove_dir_all` used to call `set_readwrite_recursive`,
1498
    /// which chmods every file in the tree to 0o644 — silently mutating the
1499
    /// shared inode's mode for every in-flight action holding a hardlink to
1500
    /// the same blob. In production this surfaced as EACCES on exec for
1501
    /// `cc_wrapper.sh` (whose CAS mode is 0o555, but eviction turned it into
1502
    /// 0o644, dropping the +x bit).
1503
    ///
1504
    /// This test models the real scenario: a "cached" directory tree whose
1505
    /// files are hardlinked to a still-active "action workspace" file. The
1506
    /// eviction cleanup runs on the cached tree, and we assert the active
1507
    /// workspace file's mode is untouched. Before the fix this test fails
1508
    /// because `set_readwrite_recursive` chmods the cached-side file, and
1509
    /// the same inode underlies the workspace file.
1510
    #[cfg(unix)]
1511
    #[nativelink_test]
1512
    async fn test_eviction_cleanup_preserves_hardlinked_file_mode() -> Result<(), Error> {
1513
        use std::os::unix::fs::{MetadataExt, PermissionsExt};
1514
1515
        use nativelink_util::fs_util::{set_dir_writable_recursive, set_readonly_recursive};
1516
1517
        let temp_dir = TempDir::new().unwrap();
1518
1519
        // Build a "cached" directory tree mimicking what DirectoryCache
1520
        // produces: a top-level dir with a nested subdir and an executable
1521
        // file inside. Mode 0o555 matches the CAS mode of an executable like
1522
        // `cc_wrapper.sh`.
1523
        let cache_entry_dir = temp_dir.path().join("cache_entry");
1524
        let nested_dir = cache_entry_dir.join("nested");
1525
        fs::create_dir_all(&nested_dir).await.unwrap();
1526
        let cached_file = nested_dir.join("cc_wrapper.sh");
1527
        fs::write(&cached_file, b"#!/bin/sh\necho hi\n")
1528
            .await
1529
            .unwrap();
1530
        fs::set_permissions(&cached_file, std::fs::Permissions::from_mode(0o555))
1531
            .await
1532
            .unwrap();
1533
1534
        // Lock the cached tree down the way DirectoryCache does after
1535
        // construction (set_readonly_recursive). This makes every file
1536
        // read-only (0o555) and leaves every directory writable (0o755).
1537
        set_readonly_recursive(&cache_entry_dir).await?;
1538
1539
        // Simulate an in-flight action workspace that has hardlinked the
1540
        // cached file. This is what `hardlink_directory_tree` does in
1541
        // `get_or_create` after the cached tree is built and locked down.
1542
        let action_workspace = temp_dir.path().join("action_workspace");
1543
        fs::create_dir_all(&action_workspace).await.unwrap();
1544
        let workspace_file = action_workspace.join("cc_wrapper.sh");
1545
        fs::hard_link(&cached_file, &workspace_file).await.unwrap();
1546
1547
        // Sanity check: cached file and workspace file share the same inode.
1548
        let cached_ino = fs::metadata(&cached_file).await.unwrap().ino();
1549
        let workspace_ino = fs::metadata(&workspace_file).await.unwrap().ino();
1550
        assert_eq!(
1551
            cached_ino, workspace_ino,
1552
            "workspace file must share inode with cached file (hardlinked)",
1553
        );
1554
        let workspace_mode_before = fs::metadata(&workspace_file)
1555
            .await
1556
            .unwrap()
1557
            .permissions()
1558
            .mode()
1559
            & 0o777;
1560
        assert_eq!(workspace_mode_before, 0o555);
1561
1562
        // Run the cleanup that `evict_lru` runs before removing the tree.
1563
        // After the fix this only chmods directories; before the fix this
1564
        // chmoded every file to 0o644, mutating the shared inode.
1565
        set_dir_writable_recursive(&cache_entry_dir).await?;
1566
1567
        // Critical assertion: the action workspace file's mode (i.e. the
1568
        // shared inode's mode) MUST be unchanged. If this fails, the cleanup
1569
        // path corrupted the inode for an in-flight action — that is the
1570
        // bug we are guarding against.
1571
        let workspace_mode_after = fs::metadata(&workspace_file)
1572
            .await
1573
            .unwrap()
1574
            .permissions()
1575
            .mode()
1576
            & 0o777;
1577
        assert_eq!(
1578
            workspace_mode_after, workspace_mode_before,
1579
            "eviction cleanup mutated the inode mode of an active workspace \
1580
             file (was 0o{workspace_mode_before:o}, now 0o{workspace_mode_after:o}); \
1581
             this is the CAS inode corruption bug",
1582
        );
1583
1584
        // We should still be able to remove the cached tree. This proves
1585
        // directory writability alone is sufficient to unlink files on unix.
1586
        fs::remove_dir_all(&cache_entry_dir).await.unwrap();
1587
        assert!(!cache_entry_dir.exists());
1588
1589
        // The workspace's file is still intact and the mode survives even
1590
        // after the cached tree is gone.
1591
        assert!(workspace_file.exists());
1592
        let workspace_mode_after_remove = fs::metadata(&workspace_file)
1593
            .await
1594
            .unwrap()
1595
            .permissions()
1596
            .mode()
1597
            & 0o777;
1598
        assert_eq!(workspace_mode_after_remove, workspace_mode_before);
1599
1600
        Ok(())
1601
    }
1602
1603
    /// Builds a nested directory tree in the CAS: a root directory containing
1604
    /// one file plus a subdirectory, and the subdirectory in turn containing a
1605
    /// file. Returns the `FastSlowStore` and the root directory's digest. Uses
1606
    /// the same `make_fast_slow_store` + `upload_blob` shape as the other tests
1607
    /// so the store matches `DirectoryCache::new`'s `Arc<FastSlowStore>` arg.
1608
    ///
1609
    /// Only used by `test_materialized_tree_dirs_writable_files_readonly`,
1610
    /// which is `#[cfg(unix)]`; gated to match so non-unix builds (Windows)
1611
    /// do not flag this helper as dead code.
1612
    #[cfg(unix)]
1613
1
    async fn setup_nested_test_store(temp_dir: &TempDir) -> (Arc<FastSlowStore>, DigestInfo) {
1614
1
        let (cas_store, slow_store) = make_fast_slow_store(temp_dir).await;
1615
1616
        // A file shared by both the root and the nested subdirectory.
1617
1
        let file_digest = upload_blob(&slow_store, 10, b"Hello, World!").await;
1618
1619
        // The nested subdirectory: contains a single file.
1620
1
        let subdir = ProtoDirectory {
1621
1
            files: vec![FileNode {
1622
1
                name: "nested.txt".to_string(),
1623
1
                digest: Some(file_digest.into()),
1624
1
                is_executable: false,
1625
1
                ..Default::default()
1626
1
            }],
1627
1
            directories: vec![],
1628
1
            symlinks: vec![],
1629
1
            ..Default::default()
1630
1
        };
1631
1
        let mut subdir_data = Vec::new();
1632
1
        subdir.encode(&mut subdir_data).unwrap();
1633
1
        let subdir_digest = upload_blob(&slow_store, 11, &subdir_data).await;
1634
1635
        // The root directory: one file plus the subdirectory above.
1636
1
        let root = ProtoDirectory {
1637
1
            files: vec![FileNode {
1638
1
                name: "root.txt".to_string(),
1639
1
                digest: Some(file_digest.into()),
1640
1
                is_executable: false,
1641
1
                ..Default::default()
1642
1
            }],
1643
1
            directories: vec![DirectoryNode {
1644
1
                name: "subdir".to_string(),
1645
1
                digest: Some(subdir_digest.into()),
1646
1
            }],
1647
1
            symlinks: vec![],
1648
1
            ..Default::default()
1649
1
        };
1650
1
        let mut root_data = Vec::new();
1651
1
        root.encode(&mut root_data).unwrap();
1652
1
        let root_digest = upload_blob(&slow_store, 12, &root_data).await;
1653
1654
1
        (cas_store, root_digest)
1655
1
    }
1656
1657
    /// Asserts every directory in `root` (the root itself and every nested
1658
    /// subdirectory) is writable and every file is read-only.
1659
    #[cfg(unix)]
1660
8
    fn assert_dirs_writable_files_readonly(
1661
8
        root: &Path,
1662
8
    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
1663
8
        Box::pin(async move {
1664
            use std::os::unix::fs::PermissionsExt;
1665
1666
8
            let metadata = fs::symlink_metadata(root)
1667
8
                .await
1668
8
                .err_tip(|| 
format!0
("metadata for {}",
root0
.
display0
()))
?0
;
1669
8
            let mode = metadata.permissions().mode() & 0o777;
1670
1671
8
            if metadata.is_dir() {
1672
4
                assert_eq!(
1673
4
                    mode & 0o200,
1674
                    0o200,
1675
                    "directory {} must be writable (mode 0o{mode:o})",
1676
0
                    root.display(),
1677
                );
1678
4
                let mut entries = fs::read_dir(root).await
?0
;
1679
10
                while let Some(
entry6
) = entries.next_entry().await
?0
{
1680
6
                    assert_dirs_writable_files_readonly(&entry.path()).await
?0
;
1681
                }
1682
4
            } else if metadata.is_file() {
1683
4
                assert_eq!(
1684
4
                    mode & 0o222,
1685
                    0,
1686
                    "file {} must be read-only (mode 0o{mode:o})",
1687
0
                    root.display(),
1688
                );
1689
0
            }
1690
            // Symlinks: mode is not meaningful, skip.
1691
1692
8
            Ok(())
1693
8
        })
1694
8
    }
1695
1696
    /// After `get_or_create` materializes a tree — on both the fresh
1697
    /// cache-miss path and the cache-hit path — every directory in the
1698
    /// destination must be writable (so Bazel actions can create outputs at
1699
    /// nested declared paths) and every file must be read-only (the file
1700
    /// inodes are CAS-hardlinked; chmoding them would corrupt the shared
1701
    /// inode). `prepare_action_inputs` relies on this so it no longer needs a
1702
    /// separate `set_dir_writable_recursive` post-walk.
1703
    #[cfg(unix)]
1704
    #[nativelink_test]
1705
    async fn test_materialized_tree_dirs_writable_files_readonly() -> Result<(), Error> {
1706
        let temp_dir = TempDir::new().unwrap();
1707
        let cache_root = temp_dir.path().join("cache");
1708
        let (store, root_digest) = setup_nested_test_store(&temp_dir).await;
1709
1710
        let config = DirectoryCacheConfig {
1711
            max_entries: 10,
1712
            max_size_bytes: 1024 * 1024,
1713
            cache_root,
1714
            ..Default::default()
1715
        };
1716
        let cache = DirectoryCache::new(config, store).await?;
1717
1718
        // Fresh-materialize path (cache miss).
1719
        let miss_dest = temp_dir.path().join("dest_miss");
1720
        let hit = cache.get_or_create(root_digest, &miss_dest).await?;
1721
        assert!(!hit, "first access must be a cache miss");
1722
        assert!(miss_dest.join("subdir").join("nested.txt").exists());
1723
        assert_dirs_writable_files_readonly(&miss_dest).await?;
1724
1725
        // A nested output can be created with no separate chmod walk.
1726
        let nested_output = miss_dest.join("subdir").join("output.o");
1727
        fs::write(&nested_output, b"action output").await.err_tip(
1728
            || "creating a nested output must succeed without set_dir_writable_recursive",
1729
        )?;
1730
1731
        // Cache-hit path: a second materialization of the same digest.
1732
        let hit_dest = temp_dir.path().join("dest_hit");
1733
        let hit = cache.get_or_create(root_digest, &hit_dest).await?;
1734
        assert!(hit, "second access must be a cache hit");
1735
        assert!(hit_dest.join("subdir").join("nested.txt").exists());
1736
        assert_dirs_writable_files_readonly(&hit_dest).await?;
1737
1738
        // The cache-hit destination also accepts a nested output directly.
1739
        fs::write(hit_dest.join("subdir").join("output.o"), b"action output")
1740
            .await
1741
            .err_tip(|| "cache-hit destination must accept a nested output directly")?;
1742
1743
        Ok(())
1744
    }
1745
1746
    /// OPT #1: a non-executable file in a cache entry must be a hardlink to
1747
    /// the `FilesystemStore` CAS blob — sharing the same inode — rather than a
1748
    /// fresh copy. This is the zero-copy materialization the optimization
1749
    /// delivers.
1750
    #[cfg(unix)]
1751
    #[nativelink_test]
1752
    async fn test_construct_hardlinks_cas_blob() -> Result<(), Error> {
1753
        use std::os::unix::fs::MetadataExt;
1754
1755
        let temp_dir = TempDir::new().unwrap();
1756
        let cache_root = temp_dir.path().join("cache");
1757
        let (store, dir_digest) = setup_test_store(&temp_dir).await;
1758
1759
        // Resolve the filesystem-tier CAS blob path for the file before
1760
        // construction so we can compare inodes afterwards.
1761
        let filesystem_store = store
1762
            .fast_store()
1763
            .downcast_ref::<FilesystemStore>(None)
1764
            .unwrap()
1765
            .get_arc()
1766
            .unwrap();
1767
        // Pull the blob into the fast tier (construction does this too).
1768
        store
1769
            .populate_fast_store(StoreKey::Digest(DigestInfo::new([1u8; 32], 13)))
1770
            .await?;
1771
        let cas_ino = filesystem_store
1772
            .get_file_entry_for_digest(&DigestInfo::new([1u8; 32], 13))
1773
            .await?
1774
2
            .get_file_path_locked(|p| async move 
{1
Ok(
fs::metadata1
(&p).await
?0
.
ino1
()) })
1775
            .await?;
1776
1777
        let config = DirectoryCacheConfig {
1778
            max_entries: 10,
1779
            max_size_bytes: 1024 * 1024,
1780
            cache_root,
1781
            ..Default::default()
1782
        };
1783
        let cache = DirectoryCache::new(config, store).await?;
1784
1785
        let dest = temp_dir.path().join("dest");
1786
        let hit = cache.get_or_create(dir_digest, &dest).await?;
1787
        assert!(!hit, "first access is a miss");
1788
1789
        // The cache entry's file (not yet the dest, which is a clone on macOS)
1790
        // must share the CAS inode. The cache entry path is cache_root/<digest>.
1791
        let cache_entry_file = cache.get_cache_path(&dir_digest).join("test.txt");
1792
        let entry_ino = fs::metadata(&cache_entry_file).await?.ino();
1793
        assert_eq!(
1794
            entry_ino, cas_ino,
1795
            "cache-entry file must be hardlinked to the CAS blob inode (zero-copy)"
1796
        );
1797
1798
        // Content must still be byte-identical.
1799
        assert_eq!(
1800
            fs::read(&cache_entry_file).await?,
1801
            b"Hello, World!",
1802
            "hardlinked file content must match the CAS blob"
1803
        );
1804
1805
        Ok(())
1806
    }
1807
1808
    /// OPT #1 correctness: an executable file must NOT be hardlinked to the
1809
    /// shared CAS blob (chmoding it would corrupt the inode shared with the
1810
    /// CAS and every other action — the PR #2347 bug). It must instead get
1811
    /// its own private inode AND carry the +x bit.
1812
    #[cfg(unix)]
1813
    #[nativelink_test]
1814
    async fn test_construct_executable_gets_private_inode() -> Result<(), Error> {
1815
        use std::os::unix::fs::{MetadataExt, PermissionsExt};
1816
1817
        let temp_dir = TempDir::new().unwrap();
1818
        let cache_root = temp_dir.path().join("cache");
1819
        let (cas_store, slow_store) = make_fast_slow_store(&temp_dir).await;
1820
1821
        let script = b"#!/bin/sh\necho ran\n";
1822
        let file_digest = upload_blob(&slow_store, 7, script).await;
1823
1824
        let directory = ProtoDirectory {
1825
            files: vec![FileNode {
1826
                name: "run.sh".to_string(),
1827
                digest: Some(file_digest.into()),
1828
                is_executable: true,
1829
                ..Default::default()
1830
            }],
1831
            ..Default::default()
1832
        };
1833
        let mut dir_data = Vec::new();
1834
        directory.encode(&mut dir_data).unwrap();
1835
        let dir_digest = upload_blob(&slow_store, 8, &dir_data).await;
1836
1837
        // Resolve the CAS blob inode for the executable.
1838
        cas_store
1839
            .populate_fast_store(StoreKey::Digest(file_digest))
1840
            .await?;
1841
        let filesystem_store = cas_store
1842
            .fast_store()
1843
            .downcast_ref::<FilesystemStore>(None)
1844
            .unwrap()
1845
            .get_arc()
1846
            .unwrap();
1847
        let (cas_ino, cas_mode) = filesystem_store
1848
            .get_file_entry_for_digest(&file_digest)
1849
            .await?
1850
1
            .get_file_path_locked(|p| async move {
1851
1
                let m = fs::metadata(&p).await
?0
;
1852
1
                Ok((m.ino(), m.permissions().mode() & 0o777))
1853
2
            })
1854
            .await?;
1855
1856
        let config = DirectoryCacheConfig {
1857
            max_entries: 10,
1858
            max_size_bytes: 1024 * 1024,
1859
            cache_root,
1860
            ..Default::default()
1861
        };
1862
        let cache = DirectoryCache::new(config, cas_store).await?;
1863
1864
        let dest = temp_dir.path().join("dest");
1865
        cache.get_or_create(dir_digest, &dest).await?;
1866
1867
        let cache_entry_file = cache.get_cache_path(&dir_digest).join("run.sh");
1868
        let entry_meta = fs::metadata(&cache_entry_file).await?;
1869
        let entry_mode = entry_meta.permissions().mode() & 0o777;
1870
1871
        // Private inode: distinct from the shared CAS blob.
1872
        assert_ne!(
1873
            entry_meta.ino(),
1874
            cas_ino,
1875
            "executable must have its own inode, not the shared CAS blob inode"
1876
        );
1877
        // The +x bit is set on the cache entry.
1878
        assert_ne!(entry_mode & 0o111, 0, "executable bit must be set");
1879
        // Content byte-identical.
1880
        assert_eq!(fs::read(&cache_entry_file).await?, script);
1881
        // The CAS blob's mode was NOT mutated by the chmod of the private copy.
1882
        let cas_mode_after = filesystem_store
1883
            .get_file_entry_for_digest(&file_digest)
1884
            .await?
1885
1
            .get_file_path_locked(|p| async move {
1886
1
                Ok(fs::metadata(&p).await
?0
.permissions().mode() & 0o777)
1887
2
            })
1888
            .await?;
1889
        assert_eq!(
1890
            cas_mode_after, cas_mode,
1891
            "CAS blob mode must be untouched by the executable's private chmod"
1892
        );
1893
1894
        Ok(())
1895
    }
1896
1897
    /// OPT #1 fallback: when the CAS blob lives only in the slow tier and is
1898
    /// not locally hardlinkable, construction must still succeed by copying.
1899
    /// `populate_fast_store` resolves this in practice, but the fetch+write
1900
    /// fallback must remain correct and produce identical content.
1901
    #[nativelink_test]
1902
    async fn test_construct_file_content_roundtrip() -> Result<(), Error> {
1903
        let temp_dir = TempDir::new().unwrap();
1904
        let cache_root = temp_dir.path().join("cache");
1905
        let (store, dir_digest) = setup_test_store(&temp_dir).await;
1906
1907
        let config = DirectoryCacheConfig {
1908
            max_entries: 10,
1909
            max_size_bytes: 1024 * 1024,
1910
            cache_root,
1911
            ..Default::default()
1912
        };
1913
        let cache = DirectoryCache::new(config, store).await?;
1914
1915
        let dest = temp_dir.path().join("dest");
1916
        cache.get_or_create(dir_digest, &dest).await?;
1917
        assert_eq!(
1918
            fs::read(dest.join("test.txt")).await?,
1919
            b"Hello, World!",
1920
            "materialized content must round-trip the CAS blob exactly"
1921
        );
1922
1923
        Ok(())
1924
    }
1925
1926
    /// OPT #2: the cache entry's recorded size must equal the sum of
1927
    /// `FileNode.digest.size_bytes` across the whole (nested) tree —
1928
    /// accumulated during construction, with no post-hoc filesystem walk.
1929
    #[nativelink_test]
1930
    async fn test_size_accounting_from_digest_sizes() -> Result<(), Error> {
1931
        let temp_dir = TempDir::new().unwrap();
1932
        let cache_root = temp_dir.path().join("cache");
1933
        let (cas_store, slow_store) = make_fast_slow_store(&temp_dir).await;
1934
1935
        // Two files at the root, one file in a nested subdir.
1936
        let f1 = upload_blob(&slow_store, 10, b"aaaaaaaa").await; // 8 bytes
1937
        let f2 = upload_blob(&slow_store, 11, b"bbb").await; // 3 bytes
1938
        let f3 = upload_blob(&slow_store, 12, b"ccccc").await; // 5 bytes
1939
1940
        let sub = ProtoDirectory {
1941
            files: vec![FileNode {
1942
                name: "nested.bin".to_string(),
1943
                digest: Some(f3.into()),
1944
                is_executable: false,
1945
                ..Default::default()
1946
            }],
1947
            ..Default::default()
1948
        };
1949
        let mut sub_data = Vec::new();
1950
        sub.encode(&mut sub_data).unwrap();
1951
        let sub_digest = upload_blob(&slow_store, 13, &sub_data).await;
1952
1953
        let root = ProtoDirectory {
1954
            files: vec![
1955
                FileNode {
1956
                    name: "a.bin".to_string(),
1957
                    digest: Some(f1.into()),
1958
                    is_executable: false,
1959
                    ..Default::default()
1960
                },
1961
                FileNode {
1962
                    name: "b.bin".to_string(),
1963
                    digest: Some(f2.into()),
1964
                    is_executable: false,
1965
                    ..Default::default()
1966
                },
1967
            ],
1968
            directories: vec![DirectoryNode {
1969
                name: "sub".to_string(),
1970
                digest: Some(sub_digest.into()),
1971
            }],
1972
            ..Default::default()
1973
        };
1974
        let mut root_data = Vec::new();
1975
        root.encode(&mut root_data).unwrap();
1976
        let root_digest = upload_blob(&slow_store, 14, &root_data).await;
1977
1978
        let config = DirectoryCacheConfig {
1979
            max_entries: 10,
1980
            max_size_bytes: 1024 * 1024,
1981
            cache_root,
1982
            ..Default::default()
1983
        };
1984
        let cache = DirectoryCache::new(config, cas_store).await?;
1985
1986
        let dest = temp_dir.path().join("dest");
1987
        cache.get_or_create(root_digest, &dest).await?;
1988
1989
        let stats = cache.stats().await;
1990
        assert_eq!(
1991
            stats.total_size_bytes,
1992
            8 + 3 + 5,
1993
            "cache size must be the sum of all FileNode digest sizes (incl. nested)"
1994
        );
1995
1996
        Ok(())
1997
    }
1998
1999
    /// OPT #2: every directory in a cache entry — root and nested — must be
2000
    /// left at mode 0o755, set at creation time without a separate walk.
2001
    #[cfg(unix)]
2002
    #[nativelink_test]
2003
    async fn test_cache_entry_dirs_are_writable() -> Result<(), Error> {
2004
        use std::os::unix::fs::PermissionsExt;
2005
2006
        let temp_dir = TempDir::new().unwrap();
2007
        let cache_root = temp_dir.path().join("cache");
2008
        let (cas_store, slow_store) = make_fast_slow_store(&temp_dir).await;
2009
2010
        let f = upload_blob(&slow_store, 20, b"data").await;
2011
        let sub = ProtoDirectory {
2012
            files: vec![FileNode {
2013
                name: "leaf.txt".to_string(),
2014
                digest: Some(f.into()),
2015
                is_executable: false,
2016
                ..Default::default()
2017
            }],
2018
            ..Default::default()
2019
        };
2020
        let mut sub_data = Vec::new();
2021
        sub.encode(&mut sub_data).unwrap();
2022
        let sub_digest = upload_blob(&slow_store, 21, &sub_data).await;
2023
2024
        let root = ProtoDirectory {
2025
            directories: vec![DirectoryNode {
2026
                name: "sub".to_string(),
2027
                digest: Some(sub_digest.into()),
2028
            }],
2029
            ..Default::default()
2030
        };
2031
        let mut root_data = Vec::new();
2032
        root.encode(&mut root_data).unwrap();
2033
        let root_digest = upload_blob(&slow_store, 22, &root_data).await;
2034
2035
        let config = DirectoryCacheConfig {
2036
            max_entries: 10,
2037
            max_size_bytes: 1024 * 1024,
2038
            cache_root,
2039
            ..Default::default()
2040
        };
2041
        let cache = DirectoryCache::new(config, cas_store).await?;
2042
        let dest = temp_dir.path().join("dest");
2043
        cache.get_or_create(root_digest, &dest).await?;
2044
2045
        let entry_root = cache.get_cache_path(&root_digest);
2046
        for dir in [entry_root.clone(), entry_root.join("sub")] {
2047
            let mode = fs::metadata(&dir).await?.permissions().mode() & 0o777;
2048
            assert_eq!(
2049
                mode,
2050
                0o755,
2051
                "cache-entry directory {} must be 0o755",
2052
                dir.display()
2053
            );
2054
        }
2055
2056
        Ok(())
2057
    }
2058
2059
    /// OPT #5: many concurrent `get_or_create` calls for the *same* digest
2060
    /// must be single-flighted — the directory is constructed exactly once
2061
    /// and every caller materializes its own destination from that single
2062
    /// cache entry. Single-flight is observable through the hit/miss return:
2063
    /// exactly one call sees a miss (`false` — it did the construct), all
2064
    /// others see a hit (`true`). Every destination must also be correct.
2065
    ///
2066
    /// Runs on a multi-threaded runtime so the calls truly race.
2067
    #[nativelink_test(flavor = "multi_thread", worker_threads = 4)]
2068
    async fn test_concurrent_get_or_create_single_flight() -> Result<(), Error> {
2069
        use futures::future::join_all;
2070
2071
        let temp_dir = TempDir::new().unwrap();
2072
        let cache_root = temp_dir.path().join("cache");
2073
        let (store, dir_digest) = setup_test_store(&temp_dir).await;
2074
2075
        let config = DirectoryCacheConfig {
2076
            max_entries: 10,
2077
            max_size_bytes: 1024 * 1024,
2078
            cache_root,
2079
            ..Default::default()
2080
        };
2081
        let cache = Arc::new(DirectoryCache::new(config, store).await?);
2082
2083
        // Fire 16 concurrent requests for the same digest, each to its own
2084
        // destination.
2085
        #[allow(clippy::items_after_statements)]
2086
        const N: usize = 16;
2087
        let dests: Vec<PathBuf> = (0..N)
2088
16
            .map(|i| temp_dir.path().join(format!("dest_{i}")))
2089
            .collect();
2090
16
        let futures = dests.iter().map(|dest| {
2091
16
            let cache = Arc::clone(&cache);
2092
16
            let dest = dest.clone();
2093
16
            async move { cache.get_or_create(dir_digest, &dest).await }
2094
16
        });
2095
        let results: Vec<bool> = join_all(futures)
2096
            .await
2097
            .into_iter()
2098
            .collect::<Result<_, _>>()?;
2099
2100
        // Exactly one construction (one miss); the rest are cache hits.
2101
16
        let misses = results.iter().filter(|hit| !**hit).count();
2102
        assert_eq!(
2103
            misses, 1,
2104
            "exactly one caller should construct the directory (single-flight)"
2105
        );
2106
        assert_eq!(results.iter().filter(|hit| **hit).count(), N - 1);
2107
2108
        // The cache holds exactly one entry, and every destination has the
2109
        // correct, byte-identical content.
2110
        let stats = cache.stats().await;
2111
        assert_eq!(stats.entries, 1, "digest must be cached exactly once");
2112
        for dest in &dests {
2113
            assert_eq!(
2114
                fs::read(dest.join("test.txt")).await?,
2115
                b"Hello, World!",
2116
                "every concurrently-materialized destination must be correct"
2117
            );
2118
        }
2119
2120
        Ok(())
2121
    }
2122
}