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