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