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