Skip to main content

aft/
root_cache.rs

1//! These files coordinate safe access to a project-root cache: a writer lease
2//! ensures only one process updates the cache at a time, and read-marker files
3//! let cleanup see which readers are still using the cache.
4//!
5//! Writer leases are stored at `<storage>/callgraph/<artifact_cache_key>/writer.lease`
6//! and `<storage>/inspect/<project_scope_key>/writer.lease`. They use the
7//! `fs_lock` JSON format with a `writer_epoch` nonce so a writer can detect if
8//! another process has taken over before publishing changes or starting SQLite
9//! write transactions.
10//!
11//! Read markers track active SQLite readers so cache cleanup can tell when it is
12//! safe to remove old data. They are stored under
13//! `<cache-domain>/readers/<generation-label>/<pid>.<hostname>.<created_at_ms>.<seq>.json`;
14//! the JSON records the process identity and creation time, mtime is used as a
15//! heartbeat for cleanup across hosts, and the PID is used for cleanup on the
16//! same host. Marker files are created `0600` so they do not expose checkout
17//! activity or let another local user delete a protected marker.
18
19use std::collections::{HashMap, HashSet};
20use std::fs::{self, File, OpenOptions};
21use std::io::{self, Write};
22use std::path::{Path, PathBuf};
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
24use std::sync::{Arc, Mutex, OnceLock, Weak};
25use std::time::{Duration, SystemTime, UNIX_EPOCH};
26
27use serde::{Deserialize, Serialize};
28
29use crate::fs_lock;
30
31static MARKER_SEQ: AtomicU64 = AtomicU64::new(0);
32static LIVE_SCOPE_KEYS: OnceLock<Mutex<HashMap<(PathBuf, String), usize>>> = OnceLock::new();
33
34/// Read-marker heartbeats refresh no more often than the filesystem lock
35/// heartbeat. Active readers piggyback this on normal read paths instead of
36/// spawning a thread per connection.
37pub const READ_MARKER_TOUCH_INTERVAL_MS: u64 = fs_lock::HEARTBEAT_INTERVAL_MS;
38/// Cross-host markers cannot use local PID liveness, so they expire after the
39/// same conservative 5x stale-heartbeat window used by filesystem locks.
40pub const READ_MARKER_CROSS_HOST_STALE_MS: u64 = fs_lock::STALE_HEARTBEAT_MS * 5;
41// Process start timestamps are not always millisecond-precise across OS APIs.
42// A one-second grace keeps a marker created immediately after process launch
43// attached to that process while still identifying clear PID reuse.
44const PROCESS_START_TIME_GRACE_MS: u64 = 1_000;
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
47pub enum RootCacheDomain {
48    Callgraph,
49    Inspect,
50}
51
52impl RootCacheDomain {
53    pub fn as_str(self) -> &'static str {
54        match self {
55            RootCacheDomain::Callgraph => "callgraph",
56            RootCacheDomain::Inspect => "inspect",
57        }
58    }
59}
60
61/// Serializes artifact supersession with the final disk publication step.
62/// Advancing an epoch either happens before a stale worker checks and prevents
63/// its publish, or after that worker has fully published the still-current
64/// generation. This closes the check-then-publish race of a bare atomic epoch.
65#[derive(Clone, Default)]
66pub struct ArtifactPublishEpoch {
67    current: Arc<parking_lot::Mutex<u64>>,
68}
69
70impl ArtifactPublishEpoch {
71    pub fn next(&self) -> u64 {
72        let mut current = self.current.lock();
73        *current = current.wrapping_add(1);
74        *current
75    }
76
77    pub fn current(&self) -> u64 {
78        *self.current.lock()
79    }
80
81    pub fn run_if_current<R>(&self, expected: u64, publish: impl FnOnce() -> R) -> Option<R> {
82        let current = self.current.lock();
83        if *current != expected {
84            return None;
85        }
86        Some(publish())
87    }
88}
89
90pub struct WriterLease {
91    domain: RootCacheDomain,
92    key: String,
93    path: PathBuf,
94    epoch: String,
95    guard: Mutex<fs_lock::LockGuard>,
96}
97
98#[derive(Clone, Debug, Eq, Hash, PartialEq)]
99struct ProcessLeaseKey {
100    domain: RootCacheDomain,
101    cache_dir: PathBuf,
102}
103
104#[derive(Clone, Debug, Eq, Hash, PartialEq)]
105struct WriterLeaseAcquisitionKey {
106    domain: RootCacheDomain,
107    key: String,
108    project_root: PathBuf,
109}
110
111static PROCESS_LEASES: OnceLock<Mutex<HashMap<ProcessLeaseKey, Weak<WriterLease>>>> =
112    OnceLock::new();
113// Same-root callers share this short-lived gate so only one thread performs the
114// filesystem lease attempt, while different roots do not wait on the registry
115// mutex during stat/probe/create/heartbeat work.
116static PROCESS_LEASE_ACQUISITIONS: OnceLock<Mutex<HashMap<ProcessLeaseKey, Weak<Mutex<()>>>>> =
117    OnceLock::new();
118static WRITER_LEASE_ACQUISITION_COUNTS: OnceLock<Mutex<HashMap<WriterLeaseAcquisitionKey, usize>>> =
119    OnceLock::new();
120static WRITER_LEASE_ACQUISITION_COUNTER_ENABLED: AtomicBool = AtomicBool::new(false);
121static CONFIGURED_ARTIFACT_ACCESS: OnceLock<Mutex<HashMap<PathBuf, ArtifactAccess>>> =
122    OnceLock::new();
123static WARNED_BORROW_ONLY_WRITES: OnceLock<Mutex<HashSet<(PathBuf, PathBuf)>>> = OnceLock::new();
124
125/// Root-scoped capability that distinguishes shared repository artifacts from
126/// mutable state private to one checkout.
127#[derive(Clone, Debug)]
128pub struct ArtifactAccess {
129    project_root: PathBuf,
130    shared_key: String,
131    private_key: String,
132    borrow_only_shared: bool,
133}
134
135impl ArtifactAccess {
136    fn configured(project_root: &Path, shared_key: &str, borrow_only_shared: bool) -> Self {
137        let project_root = canonical_root(project_root);
138        Self {
139            private_key: crate::path_identity::project_scope_key(&project_root),
140            project_root,
141            shared_key: shared_key.to_string(),
142            borrow_only_shared,
143        }
144    }
145
146    /// Resolve the capability registered during configure, probing Git only for
147    /// direct artifact API callers that have not configured an app context.
148    pub fn for_root(project_root: &Path) -> Self {
149        let project_root = canonical_root(project_root);
150        if let Some(access) = configured_artifact_access()
151            .lock()
152            .ok()
153            .and_then(|access| access.get(&project_root).cloned())
154        {
155            return access;
156        }
157        // Unregistered root: fail closed without spawning git probes. Configure
158        // registers every bound root before any store acquisition, so landing
159        // here means a direct artifact-API caller on an unconfigured root —
160        // treating it as borrow-only keeps shared artifacts safe and keeps this
161        // path subprocess-free (a git probe here has unbounded latency and can
162        // run on latency-critical threads).
163        crate::slog_warn!(
164            "artifact access requested for unconfigured root {}; defaulting to borrow-only",
165            project_root.display()
166        );
167        let shared_key = crate::path_identity::project_scope_key(&project_root);
168        Self::configured(&project_root, &shared_key, true)
169    }
170
171    /// Return whether this root may write the keyed artifact, logging the first
172    /// denial for each concrete path so read-only degradation stays observable.
173    pub fn allows_write(&self, artifact_key: &str, write_path: &Path) -> bool {
174        let writes_keyed_dir = write_path
175            .parent()
176            .and_then(Path::file_name)
177            .and_then(|name| name.to_str())
178            .is_some_and(|name| name == artifact_key);
179        if !self.borrow_only_shared
180            || artifact_key != self.shared_key
181            || artifact_key == self.private_key
182            || !writes_keyed_dir
183        {
184            return true;
185        }
186        let warning_key = (self.project_root.clone(), write_path.to_path_buf());
187        let should_warn = WARNED_BORROW_ONLY_WRITES
188            .get_or_init(|| Mutex::new(HashSet::new()))
189            .lock()
190            .map(|mut warned| {
191                if warned.len() >= 4_096 {
192                    warned.clear();
193                }
194                warned.insert(warning_key)
195            })
196            .unwrap_or(false);
197        if should_warn {
198            crate::slog_warn!(
199                "borrow-only worktree denied shared artifact write at {}",
200                write_path.display()
201            );
202        }
203        false
204    }
205}
206
207fn configured_artifact_access() -> &'static Mutex<HashMap<PathBuf, ArtifactAccess>> {
208    CONFIGURED_ARTIFACT_ACCESS.get_or_init(|| Mutex::new(HashMap::new()))
209}
210
211/// Track scope keys belonging to roots currently bound in this process. The
212/// inspect sweep snapshots this registry at publication time so a live actor's
213/// cache cannot be mistaken for a reclaimed worktree cache.
214fn live_scope_keys() -> &'static Mutex<HashMap<(PathBuf, String), usize>> {
215    LIVE_SCOPE_KEYS.get_or_init(|| Mutex::new(HashMap::new()))
216}
217
218pub(crate) fn register_live_scope(storage_root: &Path, project_root: &Path) {
219    let storage_root = canonical_root(storage_root);
220    let scope_key = crate::path_identity::project_scope_key(project_root);
221    let mut scopes = match live_scope_keys().lock() {
222        Ok(scopes) => scopes,
223        Err(poisoned) => poisoned.into_inner(),
224    };
225    *scopes.entry((storage_root, scope_key)).or_default() += 1;
226}
227
228pub(crate) fn unregister_live_scope(storage_root: &Path, project_root: &Path) {
229    let storage_root = canonical_root(storage_root);
230    let scope_key = crate::path_identity::project_scope_key(project_root);
231    let mut scopes = match live_scope_keys().lock() {
232        Ok(scopes) => scopes,
233        Err(poisoned) => poisoned.into_inner(),
234    };
235    let key = (storage_root, scope_key);
236    if let Some(count) = scopes.get_mut(&key) {
237        *count = count.saturating_sub(1);
238        if *count == 0 {
239            scopes.remove(&key);
240        }
241    }
242}
243
244pub(crate) fn live_scope_keys_for_storage(storage_root: &Path) -> HashSet<String> {
245    let storage_root = canonical_root(storage_root);
246    let scopes = match live_scope_keys().lock() {
247        Ok(scopes) => scopes,
248        Err(poisoned) => poisoned.into_inner(),
249    };
250    scopes
251        .iter()
252        .filter(|((root, _), count)| root == &storage_root && **count > 0)
253        .map(|((_, key), _)| key.clone())
254        .collect()
255}
256
257/// Register the worktree topology already detected by configure so artifact
258/// APIs can enforce it without repeating a Git subprocess on every write path.
259pub fn configure_artifact_access(project_root: &Path, shared_key: &str, borrow_only_shared: bool) {
260    let access = ArtifactAccess::configured(project_root, shared_key, borrow_only_shared);
261    if let Ok(mut configured) = configured_artifact_access().lock() {
262        // Bounded, but never a wholesale clear: dropping still-live roots'
263        // capabilities would silently flip them onto the fail-closed
264        // (borrow-only) fallback. Evict an arbitrary other entry instead —
265        // any evicted-but-live root re-registers on its next configure.
266        if configured.len() >= 4_096 && !configured.contains_key(&access.project_root) {
267            if let Some(evict) = configured.keys().next().cloned() {
268                configured.remove(&evict);
269            }
270        }
271        configured.insert(access.project_root.clone(), access);
272    }
273}
274
275fn canonical_root(project_root: &Path) -> PathBuf {
276    std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf())
277}
278
279fn process_leases() -> &'static Mutex<HashMap<ProcessLeaseKey, Weak<WriterLease>>> {
280    PROCESS_LEASES.get_or_init(|| Mutex::new(HashMap::new()))
281}
282
283fn process_lease_acquisitions() -> &'static Mutex<HashMap<ProcessLeaseKey, Weak<Mutex<()>>>> {
284    PROCESS_LEASE_ACQUISITIONS.get_or_init(|| Mutex::new(HashMap::new()))
285}
286
287fn writer_lease_acquisition_counts() -> &'static Mutex<HashMap<WriterLeaseAcquisitionKey, usize>> {
288    WRITER_LEASE_ACQUISITION_COUNTS.get_or_init(|| Mutex::new(HashMap::new()))
289}
290
291fn record_writer_lease_acquisition(domain: RootCacheDomain, key: &str, project_root: &Path) {
292    if !WRITER_LEASE_ACQUISITION_COUNTER_ENABLED.load(Ordering::Relaxed) {
293        return;
294    }
295    let project_root =
296        std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
297    let acquisition_key = WriterLeaseAcquisitionKey {
298        domain,
299        key: key.to_string(),
300        project_root,
301    };
302    if let Ok(mut counts) = writer_lease_acquisition_counts().lock() {
303        *counts.entry(acquisition_key).or_default() += 1;
304    }
305}
306
307#[doc(hidden)]
308pub fn reset_writer_lease_acquisition_counts_for_test() {
309    WRITER_LEASE_ACQUISITION_COUNTER_ENABLED.store(true, Ordering::Relaxed);
310    if let Ok(mut counts) = writer_lease_acquisition_counts().lock() {
311        counts.clear();
312    }
313}
314
315#[doc(hidden)]
316pub fn writer_lease_acquisition_count_for_test(
317    domain: RootCacheDomain,
318    key: &str,
319    project_root: &Path,
320) -> usize {
321    let project_root =
322        std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
323    writer_lease_acquisition_counts()
324        .lock()
325        .ok()
326        .and_then(|counts| {
327            counts
328                .get(&WriterLeaseAcquisitionKey {
329                    domain,
330                    key: key.to_string(),
331                    project_root,
332                })
333                .copied()
334        })
335        .unwrap_or(0)
336}
337
338fn shared_process_lease(
339    registry_key: &ProcessLeaseKey,
340) -> Result<Option<Arc<WriterLease>>, fs_lock::AcquireError> {
341    let mut leases = process_leases().lock().map_err(|_| {
342        fs_lock::AcquireError::Io(io::Error::other("process lease registry poisoned"))
343    })?;
344    if let Some(existing) = leases.get(registry_key).and_then(Weak::upgrade) {
345        if existing.verify()? {
346            return Ok(Some(existing));
347        }
348        leases.remove(registry_key);
349    }
350    Ok(None)
351}
352
353fn process_lease_acquisition_lock(
354    registry_key: &ProcessLeaseKey,
355) -> Result<Arc<Mutex<()>>, fs_lock::AcquireError> {
356    let mut acquisitions = process_lease_acquisitions().lock().map_err(|_| {
357        fs_lock::AcquireError::Io(io::Error::other(
358            "process lease acquisition registry poisoned",
359        ))
360    })?;
361    if let Some(existing) = acquisitions.get(registry_key).and_then(Weak::upgrade) {
362        return Ok(existing);
363    }
364    if acquisitions.len() > 1024 {
365        acquisitions.retain(|_, lock| lock.strong_count() > 0);
366    }
367    let lock = Arc::new(Mutex::new(()));
368    acquisitions.insert(registry_key.clone(), Arc::downgrade(&lock));
369    Ok(lock)
370}
371
372#[cfg(test)]
373type AcquireSharedHook = Arc<dyn Fn(RootCacheDomain, &Path, &str) + Send + Sync + 'static>;
374
375#[cfg(test)]
376static ACQUIRE_SHARED_HOOK: OnceLock<Mutex<Option<AcquireSharedHook>>> = OnceLock::new();
377
378#[cfg(test)]
379fn set_acquire_shared_hook_for_test(hook: Option<AcquireSharedHook>) {
380    *ACQUIRE_SHARED_HOOK
381        .get_or_init(|| Mutex::new(None))
382        .lock()
383        .expect("acquire shared hook mutex") = hook;
384}
385
386#[cfg(test)]
387fn run_acquire_shared_hook_for_test(domain: RootCacheDomain, cache_dir: &Path, key: &str) {
388    let hook = ACQUIRE_SHARED_HOOK
389        .get_or_init(|| Mutex::new(None))
390        .lock()
391        .expect("acquire shared hook mutex")
392        .clone();
393    if let Some(hook) = hook {
394        hook(domain, cache_dir, key);
395    }
396}
397
398#[cfg(not(test))]
399fn run_acquire_shared_hook_for_test(_domain: RootCacheDomain, _cache_dir: &Path, _key: &str) {}
400
401impl std::fmt::Debug for WriterLease {
402    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403        formatter
404            .debug_struct("WriterLease")
405            .field("domain", &self.domain)
406            .field("key", &self.key)
407            .field("path", &self.path)
408            .field("epoch", &self.epoch)
409            .finish_non_exhaustive()
410    }
411}
412
413impl WriterLease {
414    pub fn acquire_shared(
415        domain: RootCacheDomain,
416        cache_dir: &Path,
417        key: &str,
418        project_root: &Path,
419    ) -> Result<Option<Arc<Self>>, fs_lock::AcquireError> {
420        let access = ArtifactAccess::for_root(project_root);
421        if !access.allows_write(key, &writer_lease_path(cache_dir)) {
422            return Ok(None);
423        }
424        let registry_key = ProcessLeaseKey {
425            domain,
426            cache_dir: canonical_process_lease_dir(cache_dir),
427        };
428        if let Some(existing) = shared_process_lease(&registry_key)? {
429            record_writer_lease_acquisition(domain, key, project_root);
430            return Ok(Some(existing));
431        }
432
433        let acquisition_lock = process_lease_acquisition_lock(&registry_key)?;
434        let _acquisition_guard = acquisition_lock.lock().map_err(|_| {
435            fs_lock::AcquireError::Io(io::Error::other("process lease acquisition poisoned"))
436        })?;
437
438        if let Some(existing) = shared_process_lease(&registry_key)? {
439            record_writer_lease_acquisition(domain, key, project_root);
440            return Ok(Some(existing));
441        }
442
443        run_acquire_shared_hook_for_test(domain, cache_dir, key);
444
445        let lease = Arc::new(Self::acquire(domain, cache_dir, key, Duration::ZERO)?);
446        process_leases()
447            .lock()
448            .map_err(|_| {
449                fs_lock::AcquireError::Io(io::Error::other("process lease registry poisoned"))
450            })?
451            .insert(registry_key, Arc::downgrade(&lease));
452        record_writer_lease_acquisition(domain, key, project_root);
453        Ok(Some(lease))
454    }
455
456    fn acquire(
457        domain: RootCacheDomain,
458        cache_dir: &Path,
459        key: &str,
460        timeout: Duration,
461    ) -> Result<Self, fs_lock::AcquireError> {
462        if !storage_allows_root_keyed(cache_dir)? {
463            return Err(fs_lock::AcquireError::Io(io::Error::new(
464                io::ErrorKind::PermissionDenied,
465                format!(
466                    "refusing root-keyed {} writer lease on a network filesystem at {}",
467                    domain.as_str(),
468                    cache_dir.display()
469                ),
470            )));
471        }
472        if let Some(storage_root) = cache_dir.parent().and_then(Path::parent) {
473            crate::legacy_partitions::guard_new_layout_write_path(
474                storage_root,
475                cache_dir,
476                "root-keyed writer lease",
477            )?;
478        }
479        fs::create_dir_all(cache_dir)?;
480        let guard = fs_lock::try_acquire(&writer_lease_path(cache_dir), timeout)?;
481        if !guard.verify_writer_epoch()? {
482            return Err(fs_lock::AcquireError::Io(io::Error::other(
483                "writer lease epoch changed immediately after acquisition",
484            )));
485        }
486        let path = guard.path().to_path_buf();
487        let epoch = guard.writer_epoch().to_string();
488        Ok(Self {
489            domain,
490            key: key.to_string(),
491            path,
492            epoch,
493            guard: Mutex::new(guard),
494        })
495    }
496
497    pub fn verify(&self) -> io::Result<bool> {
498        self.guard
499            .lock()
500            .map_err(|_| io::Error::other("writer lease mutex poisoned"))?
501            .verify_writer_epoch()
502    }
503
504    pub fn epoch(&self) -> &str {
505        &self.epoch
506    }
507
508    pub fn domain(&self) -> RootCacheDomain {
509        self.domain
510    }
511
512    pub fn key(&self) -> &str {
513        &self.key
514    }
515
516    pub fn path(&self) -> &Path {
517        &self.path
518    }
519}
520
521pub fn writer_lease_path(cache_dir: &Path) -> PathBuf {
522    cache_dir.join("writer.lease")
523}
524
525#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
526pub struct ReadMarkerMetadata {
527    pub pid: u32,
528    pub hostname: String,
529    pub created_at_ms: u64,
530}
531
532#[derive(Debug)]
533pub struct ReadMarker {
534    path: PathBuf,
535    metadata: ReadMarkerMetadata,
536    last_touched_at_ms: AtomicU64,
537}
538
539impl ReadMarker {
540    pub fn create(cache_dir: &Path, generation_label: &str) -> io::Result<Self> {
541        let metadata = ReadMarkerMetadata {
542            pid: std::process::id(),
543            hostname: current_hostname(),
544            created_at_ms: now_ms(),
545        };
546        let dir = read_marker_dir(cache_dir, generation_label);
547        fs::create_dir_all(&dir)?;
548        let seq = MARKER_SEQ.fetch_add(1, Ordering::Relaxed);
549        let path = dir.join(format!(
550            "{}.{}.{}.{}.json",
551            metadata.pid,
552            sanitize_marker_component(&metadata.hostname),
553            metadata.created_at_ms,
554            seq
555        ));
556        write_marker_file(&path, &metadata)?;
557        let last_touched_at_ms = AtomicU64::new(metadata.created_at_ms);
558        Ok(Self {
559            path,
560            metadata,
561            last_touched_at_ms,
562        })
563    }
564
565    pub fn touch(&self) -> io::Result<()> {
566        write_marker_file(&self.path, &self.metadata)?;
567        self.last_touched_at_ms.store(now_ms(), Ordering::Relaxed);
568        Ok(())
569    }
570
571    pub fn touch_if_due(&self) -> io::Result<()> {
572        let now = now_ms();
573        let last = self.last_touched_at_ms.load(Ordering::Relaxed);
574        if now.saturating_sub(last) < READ_MARKER_TOUCH_INTERVAL_MS {
575            return Ok(());
576        }
577        self.touch()
578    }
579
580    pub fn path(&self) -> &Path {
581        &self.path
582    }
583
584    pub fn metadata(&self) -> &ReadMarkerMetadata {
585        &self.metadata
586    }
587}
588
589impl Drop for ReadMarker {
590    fn drop(&mut self) {
591        let _ = fs::remove_file(&self.path);
592        fs_lock::sync_parent(&self.path);
593    }
594}
595
596pub fn read_marker_dir(cache_dir: &Path, generation_label: &str) -> PathBuf {
597    cache_dir.join("readers").join(generation_label)
598}
599
600/// Sweep every generation marker below a cache directory, reusing the same
601/// PID-authoritative and cross-host-TTL liveness rules as generation GC.
602pub(crate) fn sweep_all_read_markers(cache_dir: &Path) -> ReadMarkerSweep {
603    let readers = cache_dir.join("readers");
604    let entries = match fs::read_dir(&readers) {
605        Ok(entries) => entries,
606        Err(error) if error.kind() == io::ErrorKind::NotFound => return ReadMarkerSweep::default(),
607        Err(_) => {
608            return ReadMarkerSweep {
609                protected: true,
610                removed_stale: 0,
611            }
612        }
613    };
614
615    let mut sweep = ReadMarkerSweep::default();
616    for entry in entries {
617        let Ok(entry) = entry else {
618            sweep.protected = true;
619            continue;
620        };
621        let path = entry.path();
622        let file_type = match entry.file_type() {
623            Ok(file_type) => file_type,
624            Err(_) => {
625                sweep.protected = true;
626                continue;
627            }
628        };
629        if !file_type.is_dir() {
630            continue;
631        }
632        let Some(label) = path.file_name().and_then(|name| name.to_str()) else {
633            sweep.protected = true;
634            continue;
635        };
636        let generation = sweep_read_markers(cache_dir, label);
637        sweep.protected |= generation.protected;
638        sweep.removed_stale += generation.removed_stale;
639    }
640    sweep
641}
642
643#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
644pub struct ReadMarkerSweep {
645    pub protected: bool,
646    pub removed_stale: usize,
647}
648
649pub fn protected_read_marker_exists(cache_dir: &Path, generation_label: &str) -> bool {
650    read_marker_protection(cache_dir, generation_label, false).protected
651}
652
653pub fn sweep_read_markers(cache_dir: &Path, generation_label: &str) -> ReadMarkerSweep {
654    read_marker_protection(cache_dir, generation_label, true)
655}
656
657fn read_marker_protection(
658    cache_dir: &Path,
659    generation_label: &str,
660    remove_stale: bool,
661) -> ReadMarkerSweep {
662    let dir = read_marker_dir(cache_dir, generation_label);
663    let entries = match fs::read_dir(&dir) {
664        Ok(entries) => entries,
665        Err(error) if error.kind() == io::ErrorKind::NotFound => return ReadMarkerSweep::default(),
666        Err(_) => {
667            return ReadMarkerSweep {
668                protected: true,
669                removed_stale: 0,
670            };
671        }
672    };
673
674    let hostname = current_hostname();
675    let now = now_ms();
676    let mut sweep = ReadMarkerSweep::default();
677    for entry in entries.flatten() {
678        let path = entry.path();
679        match marker_file_is_protected(&path, now, &hostname) {
680            MarkerProtection::Protected => sweep.protected = true,
681            MarkerProtection::Stale | MarkerProtection::Malformed => {
682                if remove_stale && fs::remove_file(&path).is_ok() {
683                    fs_lock::sync_parent(&path);
684                    sweep.removed_stale += 1;
685                }
686            }
687        }
688    }
689    sweep
690}
691
692#[derive(Clone, Copy, Debug, PartialEq, Eq)]
693enum MarkerProtection {
694    Protected,
695    Stale,
696    Malformed,
697}
698
699fn marker_file_is_protected(path: &Path, now: u64, current_host: &str) -> MarkerProtection {
700    let bytes = match fs::read(path) {
701        Ok(bytes) => bytes,
702        Err(error) if error.kind() == io::ErrorKind::NotFound => return MarkerProtection::Stale,
703        Err(_) => return MarkerProtection::Protected,
704    };
705    let metadata: ReadMarkerMetadata = match serde_json::from_slice(&bytes) {
706        Ok(metadata) => metadata,
707        Err(_) => return MarkerProtection::Malformed,
708    };
709    if metadata.hostname != current_host {
710        let Ok(file_metadata) = fs::metadata(path) else {
711            return MarkerProtection::Protected;
712        };
713        let mtime_ms = file_metadata
714            .modified()
715            .ok()
716            .map(system_time_ms)
717            .unwrap_or(now);
718        let age_ms = now.saturating_sub(mtime_ms);
719        return if age_ms <= READ_MARKER_CROSS_HOST_STALE_MS {
720            MarkerProtection::Protected
721        } else {
722            MarkerProtection::Stale
723        };
724    }
725
726    if !fs_lock::process_alive(metadata.pid) {
727        return MarkerProtection::Stale;
728    }
729    if marker_matches_live_process_instance(&metadata) {
730        MarkerProtection::Protected
731    } else {
732        MarkerProtection::Stale
733    }
734}
735
736fn marker_matches_live_process_instance(metadata: &ReadMarkerMetadata) -> bool {
737    // Same-host PID liveness is authoritative for the process instance. When the
738    // OS can tell us the live PID started after this marker was created, the PID
739    // has been reused and the marker belongs to a dead prior process; otherwise
740    // a live PID protects the marker without consulting marker mtime.
741    process_start_time_ms(metadata.pid)
742        .map(|started_at_ms| {
743            started_at_ms
744                <= metadata
745                    .created_at_ms
746                    .saturating_add(PROCESS_START_TIME_GRACE_MS)
747        })
748        .unwrap_or(true)
749}
750
751fn write_marker_file(path: &Path, metadata: &ReadMarkerMetadata) -> io::Result<()> {
752    let tmp = path.with_file_name(format!(
753        ".{}.tmp.{}.{}",
754        path.file_name()
755            .and_then(|name| name.to_str())
756            .unwrap_or("reader"),
757        std::process::id(),
758        now_nanos()
759    ));
760    let result = (|| {
761        let mut file = open_private_file(&tmp)?;
762        serde_json::to_writer(&mut file, metadata).map_err(io::Error::other)?;
763        file.write_all(b"\n")?;
764        file.sync_all()?;
765        drop(file);
766        fs_lock::rename_over(&tmp, path)?;
767        fs_lock::sync_parent(path);
768        Ok(())
769    })();
770    if result.is_err() {
771        let _ = fs::remove_file(&tmp);
772    }
773    result
774}
775
776#[cfg(unix)]
777fn open_private_file(path: &Path) -> io::Result<File> {
778    use std::os::unix::fs::OpenOptionsExt;
779
780    OpenOptions::new()
781        .write(true)
782        .create_new(true)
783        .mode(0o600)
784        .open(path)
785}
786
787#[cfg(not(unix))]
788fn open_private_file(path: &Path) -> io::Result<File> {
789    OpenOptions::new().write(true).create_new(true).open(path)
790}
791
792fn sanitize_marker_component(value: &str) -> String {
793    value
794        .chars()
795        .map(|ch| {
796            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
797                ch
798            } else {
799                '_'
800            }
801        })
802        .collect()
803}
804
805#[cfg(test)]
806static FORCE_NETWORK_FS_FOR_TEST: AtomicBool = AtomicBool::new(false);
807
808#[cfg(test)]
809pub fn set_force_network_fs_for_test(enabled: bool) {
810    FORCE_NETWORK_FS_FOR_TEST.store(enabled, Ordering::SeqCst);
811}
812
813pub fn storage_allows_root_keyed(path: &Path) -> io::Result<bool> {
814    #[cfg(test)]
815    if FORCE_NETWORK_FS_FOR_TEST.load(Ordering::SeqCst) {
816        return Ok(false);
817    }
818
819    let probe = existing_ancestor(path);
820    filesystem_is_local(&probe)
821}
822
823fn canonical_process_lease_dir(path: &Path) -> PathBuf {
824    if let Ok(canonical) = std::fs::canonicalize(path) {
825        return canonical;
826    }
827
828    let normalized = lexical_normalize(path);
829    let mut missing_components = Vec::new();
830    let mut current = normalized.as_path();
831    while !current.exists() {
832        let Some(name) = current.file_name() else {
833            return normalized;
834        };
835        missing_components.push(name.to_os_string());
836        let Some(parent) = current.parent() else {
837            return normalized;
838        };
839        current = parent;
840    }
841
842    let mut canonical = std::fs::canonicalize(current).unwrap_or_else(|_| current.to_path_buf());
843    for component in missing_components.iter().rev() {
844        canonical.push(component);
845    }
846    canonical
847}
848
849fn lexical_normalize(path: &Path) -> PathBuf {
850    let mut normalized = PathBuf::new();
851    for component in path.components() {
852        match component {
853            std::path::Component::CurDir => {}
854            std::path::Component::ParentDir => {
855                normalized.pop();
856            }
857            other => normalized.push(other.as_os_str()),
858        }
859    }
860    normalized
861}
862
863fn existing_ancestor(path: &Path) -> PathBuf {
864    let mut current = path;
865    loop {
866        if current.exists() {
867            return current.to_path_buf();
868        }
869        let Some(parent) = current.parent() else {
870            return PathBuf::from(".");
871        };
872        current = parent;
873    }
874}
875
876#[cfg(target_os = "macos")]
877fn filesystem_is_local(path: &Path) -> io::Result<bool> {
878    use std::ffi::CString;
879    use std::os::unix::ffi::OsStrExt;
880
881    let c_path = CString::new(path.as_os_str().as_bytes())
882        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL byte"))?;
883    let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
884    if unsafe { libc::statfs(c_path.as_ptr(), &mut stat) } != 0 {
885        return Err(io::Error::last_os_error());
886    }
887    let nul = stat
888        .f_fstypename
889        .iter()
890        .position(|byte| *byte == 0)
891        .unwrap_or(stat.f_fstypename.len());
892    let fs_type = String::from_utf8_lossy(
893        &stat.f_fstypename[..nul]
894            .iter()
895            .map(|byte| *byte as u8)
896            .collect::<Vec<_>>(),
897    )
898    .to_ascii_lowercase();
899    Ok(!matches!(
900        fs_type.as_str(),
901        "nfs" | "smbfs" | "afpfs" | "webdav" | "fusefs"
902    ))
903}
904
905#[cfg(all(unix, not(target_os = "macos")))]
906fn filesystem_is_local(path: &Path) -> io::Result<bool> {
907    use std::ffi::CString;
908    use std::os::unix::ffi::OsStrExt;
909
910    let c_path = CString::new(path.as_os_str().as_bytes())
911        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL byte"))?;
912    let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
913    if unsafe { libc::statfs(c_path.as_ptr(), &mut stat) } != 0 {
914        return Err(io::Error::last_os_error());
915    }
916    let fs_type = stat.f_type as i64;
917    const NFS_SUPER_MAGIC: i64 = 0x6969;
918    const SMB_SUPER_MAGIC: i64 = 0x517B;
919    const CIFS_MAGIC_NUMBER: i64 = 0xFF534D42;
920    Ok(!matches!(
921        fs_type,
922        NFS_SUPER_MAGIC | SMB_SUPER_MAGIC | CIFS_MAGIC_NUMBER
923    ))
924}
925
926#[cfg(not(unix))]
927fn filesystem_is_local(_path: &Path) -> io::Result<bool> {
928    Ok(true)
929}
930
931fn now_ms() -> u64 {
932    system_time_ms(SystemTime::now())
933}
934
935fn system_time_ms(time: SystemTime) -> u64 {
936    time.duration_since(UNIX_EPOCH)
937        .unwrap_or(Duration::ZERO)
938        .as_millis() as u64
939}
940
941fn now_nanos() -> u128 {
942    SystemTime::now()
943        .duration_since(UNIX_EPOCH)
944        .unwrap_or(Duration::ZERO)
945        .as_nanos()
946}
947
948#[cfg(test)]
949static PROCESS_START_TIME_OVERRIDES: OnceLock<Mutex<HashMap<u32, Option<u64>>>> = OnceLock::new();
950
951#[cfg(test)]
952fn set_process_start_time_for_test(pid: u32, started_at_ms: Option<u64>) {
953    PROCESS_START_TIME_OVERRIDES
954        .get_or_init(|| Mutex::new(HashMap::new()))
955        .lock()
956        .expect("process start override mutex")
957        .insert(pid, started_at_ms);
958}
959
960#[cfg(test)]
961fn clear_process_start_time_for_test(pid: u32) {
962    if let Some(overrides) = PROCESS_START_TIME_OVERRIDES.get() {
963        overrides
964            .lock()
965            .expect("process start override mutex")
966            .remove(&pid);
967    }
968}
969
970#[cfg(test)]
971fn process_start_time_override(pid: u32) -> Option<Option<u64>> {
972    PROCESS_START_TIME_OVERRIDES
973        .get()
974        .and_then(|overrides| overrides.lock().ok()?.get(&pid).copied())
975}
976
977#[cfg(target_os = "linux")]
978fn process_start_time_ms(pid: u32) -> Option<u64> {
979    #[cfg(test)]
980    if let Some(override_value) = process_start_time_override(pid) {
981        return override_value;
982    }
983
984    let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
985    let after_comm = stat.rsplit_once(") ")?.1;
986    let fields = after_comm.split_whitespace().collect::<Vec<_>>();
987    let start_ticks = fields.get(19)?.parse::<u64>().ok()?;
988    let boot_time_secs = fs::read_to_string("/proc/stat")
989        .ok()?
990        .lines()
991        .find_map(|line| line.strip_prefix("btime ")?.parse::<u64>().ok())?;
992    let ticks_per_second = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
993    if ticks_per_second <= 0 {
994        return None;
995    }
996    let ticks_per_second = ticks_per_second as u64;
997    Some(
998        boot_time_secs
999            .saturating_mul(1_000)
1000            .saturating_add(start_ticks.saturating_mul(1_000) / ticks_per_second),
1001    )
1002}
1003
1004#[cfg(target_os = "macos")]
1005fn process_start_time_ms(pid: u32) -> Option<u64> {
1006    #[cfg(test)]
1007    if let Some(override_value) = process_start_time_override(pid) {
1008        return override_value;
1009    }
1010
1011    let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
1012    let info_size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
1013    let bytes = unsafe {
1014        libc::proc_pidinfo(
1015            pid as libc::c_int,
1016            libc::PROC_PIDTBSDINFO,
1017            0,
1018            (&mut info as *mut libc::proc_bsdinfo).cast(),
1019            info_size,
1020        )
1021    };
1022    if bytes != info_size {
1023        return None;
1024    }
1025    Some(
1026        info.pbi_start_tvsec
1027            .saturating_mul(1_000)
1028            .saturating_add(info.pbi_start_tvusec / 1_000),
1029    )
1030}
1031
1032#[cfg(not(any(target_os = "linux", target_os = "macos")))]
1033fn process_start_time_ms(pid: u32) -> Option<u64> {
1034    #[cfg(test)]
1035    if let Some(override_value) = process_start_time_override(pid) {
1036        return override_value;
1037    }
1038    let _ = pid;
1039    None
1040}
1041
1042#[cfg(unix)]
1043fn current_hostname() -> String {
1044    let mut buffer = [0u8; 256];
1045    let result = unsafe { libc::gethostname(buffer.as_mut_ptr().cast(), buffer.len()) };
1046    if result == 0 {
1047        let len = buffer
1048            .iter()
1049            .position(|byte| *byte == 0)
1050            .unwrap_or(buffer.len());
1051        if len > 0 {
1052            return String::from_utf8_lossy(&buffer[..len]).into_owned();
1053        }
1054    }
1055    "unknown-host".to_string()
1056}
1057
1058#[cfg(windows)]
1059fn current_hostname() -> String {
1060    std::env::var("COMPUTERNAME").unwrap_or_else(|_| "unknown-host".to_string())
1061}
1062
1063#[cfg(all(not(unix), not(windows)))]
1064fn current_hostname() -> String {
1065    "unknown-host".to_string()
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070    use super::*;
1071
1072    #[test]
1073    fn read_marker_file_is_private_and_touchable() {
1074        let dir = tempfile::tempdir().unwrap();
1075        let marker = ReadMarker::create(dir.path(), "current").unwrap();
1076        assert!(marker.path().is_file());
1077        marker.touch().unwrap();
1078        let bytes = fs::read(marker.path()).unwrap();
1079        let parsed: ReadMarkerMetadata = serde_json::from_slice(&bytes).unwrap();
1080        assert_eq!(parsed.pid, std::process::id());
1081        #[cfg(unix)]
1082        {
1083            use std::os::unix::fs::PermissionsExt;
1084            assert_eq!(
1085                fs::metadata(marker.path()).unwrap().permissions().mode() & 0o777,
1086                0o600
1087            );
1088        }
1089    }
1090
1091    #[test]
1092    fn same_host_live_marker_ignores_stale_mtime() {
1093        let dir = tempfile::tempdir().unwrap();
1094        let marker = ReadMarker::create(dir.path(), "current").unwrap();
1095        filetime::set_file_mtime(marker.path(), filetime::FileTime::from_unix_time(0, 0)).unwrap();
1096
1097        assert!(protected_read_marker_exists(dir.path(), "current"));
1098    }
1099
1100    #[test]
1101    fn sweep_removes_dead_same_host_marker() {
1102        let dir = tempfile::tempdir().unwrap();
1103        let marker_path = read_marker_dir(dir.path(), "old").join("dead.json");
1104        let metadata = ReadMarkerMetadata {
1105            pid: 0,
1106            hostname: current_hostname(),
1107            created_at_ms: now_ms(),
1108        };
1109        fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
1110        write_marker_file(&marker_path, &metadata).unwrap();
1111
1112        let sweep = sweep_read_markers(dir.path(), "old");
1113
1114        assert!(!sweep.protected);
1115        assert_eq!(sweep.removed_stale, 1);
1116        assert!(!marker_path.exists());
1117    }
1118
1119    #[test]
1120    fn sweep_removes_reused_pid_marker_when_created_at_predates_process_start() {
1121        let dir = tempfile::tempdir().unwrap();
1122        let marker_path = read_marker_dir(dir.path(), "old").join("reused.json");
1123        let pid = std::process::id();
1124        let metadata = ReadMarkerMetadata {
1125            pid,
1126            hostname: current_hostname(),
1127            created_at_ms: 1_000,
1128        };
1129        fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
1130        write_marker_file(&marker_path, &metadata).unwrap();
1131        set_process_start_time_for_test(pid, Some(10_000));
1132
1133        let sweep = sweep_read_markers(dir.path(), "old");
1134        clear_process_start_time_for_test(pid);
1135
1136        assert!(!sweep.protected);
1137        assert_eq!(sweep.removed_stale, 1);
1138        assert!(!marker_path.exists());
1139    }
1140
1141    #[test]
1142    fn sweep_removes_expired_cross_host_marker() {
1143        let dir = tempfile::tempdir().unwrap();
1144        let marker_path = read_marker_dir(dir.path(), "old").join("cross-host.json");
1145        let metadata = ReadMarkerMetadata {
1146            pid: 123,
1147            hostname: format!("other-{}", current_hostname()),
1148            created_at_ms: now_ms(),
1149        };
1150        fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
1151        write_marker_file(&marker_path, &metadata).unwrap();
1152        let stale_time = SystemTime::now()
1153            .checked_sub(Duration::from_millis(
1154                READ_MARKER_CROSS_HOST_STALE_MS.saturating_add(1_000),
1155            ))
1156            .unwrap_or(UNIX_EPOCH);
1157        filetime::set_file_mtime(
1158            &marker_path,
1159            filetime::FileTime::from_system_time(stale_time),
1160        )
1161        .unwrap();
1162
1163        let sweep = sweep_read_markers(dir.path(), "old");
1164
1165        assert!(!sweep.protected);
1166        assert_eq!(sweep.removed_stale, 1);
1167        assert!(!marker_path.exists());
1168    }
1169
1170    #[cfg(unix)]
1171    #[test]
1172    fn process_lease_dir_canonicalizes_existing_ancestor_before_cache_dir_exists() {
1173        let dir = tempfile::tempdir().unwrap();
1174        let real = dir.path().join("real");
1175        let link = dir.path().join("link");
1176        fs::create_dir_all(&real).unwrap();
1177        std::os::unix::fs::symlink(&real, &link).unwrap();
1178
1179        let missing_cache_dir = link.join("inspect").join("project");
1180        let before_create = canonical_process_lease_dir(&missing_cache_dir);
1181        fs::create_dir_all(&missing_cache_dir).unwrap();
1182        let after_create = canonical_process_lease_dir(&missing_cache_dir);
1183
1184        assert_eq!(before_create, after_create);
1185        assert!(before_create.starts_with(std::fs::canonicalize(&real).unwrap()));
1186    }
1187
1188    #[test]
1189    fn borrow_only_root_never_receives_existing_shared_writer_capability() {
1190        let storage = tempfile::tempdir().unwrap();
1191        let parent_root = tempfile::tempdir().unwrap();
1192        let worktree_root = tempfile::tempdir().unwrap();
1193        let shared_key = "shared-artifact-key";
1194        let cache_dir = storage.path().join("callgraph").join(shared_key);
1195        configure_artifact_access(parent_root.path(), shared_key, false);
1196        configure_artifact_access(worktree_root.path(), shared_key, true);
1197
1198        let parent_lease = WriterLease::acquire_shared(
1199            RootCacheDomain::Callgraph,
1200            &cache_dir,
1201            shared_key,
1202            parent_root.path(),
1203        )
1204        .unwrap()
1205        .expect("parent writer lease");
1206        reset_writer_lease_acquisition_counts_for_test();
1207
1208        let worktree_lease = WriterLease::acquire_shared(
1209            RootCacheDomain::Callgraph,
1210            &cache_dir,
1211            shared_key,
1212            worktree_root.path(),
1213        )
1214        .unwrap();
1215
1216        assert!(worktree_lease.is_none());
1217        assert!(parent_lease.verify().unwrap());
1218        assert_eq!(
1219            writer_lease_acquisition_count_for_test(
1220                RootCacheDomain::Callgraph,
1221                shared_key,
1222                worktree_root.path(),
1223            ),
1224            0
1225        );
1226    }
1227
1228    #[test]
1229    fn borrow_only_root_keeps_private_project_scope_writable() {
1230        let storage = tempfile::tempdir().unwrap();
1231        let worktree_root = tempfile::tempdir().unwrap();
1232        let shared_key = "shared-artifact-key";
1233        let private_key = crate::path_identity::project_scope_key(worktree_root.path());
1234        let cache_dir = storage.path().join("inspect").join(&private_key);
1235        configure_artifact_access(worktree_root.path(), shared_key, true);
1236
1237        let lease = WriterLease::acquire_shared(
1238            RootCacheDomain::Inspect,
1239            &cache_dir,
1240            &private_key,
1241            worktree_root.path(),
1242        )
1243        .unwrap()
1244        .expect("private inspect writer lease");
1245
1246        assert!(lease.verify().unwrap());
1247    }
1248
1249    #[test]
1250    fn writer_lease_acquire_shared_does_not_serialize_different_roots() {
1251        let dir = tempfile::tempdir().unwrap();
1252        let blocked_cache_dir = dir.path().join("callgraph").join("blocked");
1253        let free_cache_dir = dir.path().join("callgraph").join("free");
1254        let (blocked_tx, blocked_rx) = std::sync::mpsc::channel();
1255        let (release_tx, release_rx) = std::sync::mpsc::channel();
1256        let release_rx = Arc::new(Mutex::new(release_rx));
1257
1258        struct HookGuard;
1259        impl Drop for HookGuard {
1260            fn drop(&mut self) {
1261                set_acquire_shared_hook_for_test(None);
1262            }
1263        }
1264
1265        set_acquire_shared_hook_for_test(Some(Arc::new(move |_, _, key| {
1266            if key == "blocked" {
1267                blocked_tx.send(()).unwrap();
1268                release_rx.lock().unwrap().recv().unwrap();
1269            }
1270        })));
1271        let _hook_guard = HookGuard;
1272
1273        let blocked_handle = std::thread::spawn(move || {
1274            WriterLease::acquire_shared(
1275                RootCacheDomain::Callgraph,
1276                &blocked_cache_dir,
1277                "blocked",
1278                &blocked_cache_dir,
1279            )
1280            .map_err(|error| error.to_string())
1281            .and_then(|lease| lease.ok_or_else(|| "writer lease unexpectedly denied".to_string()))
1282        });
1283        blocked_rx
1284            .recv_timeout(Duration::from_secs(5))
1285            .expect("blocked root should reach acquisition hook");
1286
1287        let (free_tx, free_rx) = std::sync::mpsc::channel();
1288        let free_handle = std::thread::spawn(move || {
1289            let result = WriterLease::acquire_shared(
1290                RootCacheDomain::Callgraph,
1291                &free_cache_dir,
1292                "free",
1293                &free_cache_dir,
1294            )
1295            .map_err(|error| error.to_string())
1296            .and_then(|lease| lease.ok_or_else(|| "writer lease unexpectedly denied".to_string()));
1297            free_tx.send(result).unwrap();
1298        });
1299        let free_lease = free_rx
1300            .recv_timeout(Duration::from_secs(5))
1301            .expect("free root should not wait behind another root's acquisition")
1302            .expect("free root should acquire while another root is in acquisition");
1303        assert!(free_lease.verify().unwrap());
1304        free_handle
1305            .join()
1306            .expect("free root thread should not panic");
1307
1308        release_tx.send(()).unwrap();
1309        let blocked_lease = blocked_handle
1310            .join()
1311            .expect("blocked root thread should not panic")
1312            .expect("blocked root should acquire after release");
1313        assert!(blocked_lease.verify().unwrap());
1314    }
1315
1316    #[test]
1317    fn writer_lease_acquire_shared_reuses_single_process_lease_concurrently() {
1318        let dir = tempfile::tempdir().unwrap();
1319        let cache_dir = dir.path().join("inspect").join("project");
1320        let barrier = std::sync::Arc::new(std::sync::Barrier::new(8));
1321        let mut handles = Vec::new();
1322        for _ in 0..8 {
1323            let barrier = std::sync::Arc::clone(&barrier);
1324            let cache_dir = cache_dir.clone();
1325            handles.push(std::thread::spawn(move || {
1326                barrier.wait();
1327                WriterLease::acquire_shared(
1328                    RootCacheDomain::Inspect,
1329                    &cache_dir,
1330                    "project",
1331                    &cache_dir,
1332                )
1333                .map_err(|error| error.to_string())
1334                .and_then(|lease| {
1335                    lease.ok_or_else(|| "writer lease unexpectedly denied".to_string())
1336                })
1337            }));
1338        }
1339
1340        let leases = handles
1341            .into_iter()
1342            .map(|handle| handle.join().unwrap().unwrap())
1343            .collect::<Vec<_>>();
1344        let epoch = leases[0].epoch().to_string();
1345        let path = leases[0].path().to_path_buf();
1346        for lease in &leases {
1347            assert_eq!(lease.epoch(), epoch);
1348            assert_eq!(lease.path(), path.as_path());
1349            assert!(lease.verify().unwrap());
1350        }
1351    }
1352
1353    #[test]
1354    fn nfs_guard_test_seam_fails_closed() {
1355        set_force_network_fs_for_test(true);
1356        assert!(!storage_allows_root_keyed(Path::new(".")).unwrap());
1357        set_force_network_fs_for_test(false);
1358    }
1359}