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