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