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