From 4c9225626450daef53f43aeebfdef27e3bfd66a0 Mon Sep 17 00:00:00 2001 From: sunerpy Date: Fri, 28 Aug 2026 13:06:14 +0800 Subject: [PATCH] fix(store): allow shared leases on read-only indexes --- crates/codegraph-store/src/index_lease.rs | 30 ++++++----- crates/codegraph-store/tests/index_lease.rs | 56 +++++++++++++++++++++ 2 files changed, 73 insertions(+), 13 deletions(-) diff --git a/crates/codegraph-store/src/index_lease.rs b/crates/codegraph-store/src/index_lease.rs index d26c0f5..cb35f42 100644 --- a/crates/codegraph-store/src/index_lease.rs +++ b/crates/codegraph-store/src/index_lease.rs @@ -383,20 +383,24 @@ impl IndexLease { let initial_identity = identity_for_validated_path(&lock_path, &initial).map_err(|_| changed(&lock_path))?; checkpoint(AcquireCheckpoint::InitialMetadataValidated); - let file = OpenOptions::new() - .read(true) - .write(true) - .open(&lock_path) - .map_err(|source| { - if source.kind() == std::io::ErrorKind::NotFound { - changed(&lock_path) - } else { - IndexLeaseError::OpenLock { - path: lock_path.clone(), - source, - } + // Shared readers only need a shared kernel lock. Keeping their handle + // read-only lets status/query commands work in read-only sandboxes and + // mounts; exclusive lifecycle operations retain write access. + let mut options = OpenOptions::new(); + options.read(true); + if mode == LeaseMode::Exclusive { + options.write(true); + } + let file = options.open(&lock_path).map_err(|source| { + if source.kind() == std::io::ErrorKind::NotFound { + changed(&lock_path) + } else { + IndexLeaseError::OpenLock { + path: lock_path.clone(), + source, } - })?; + } + })?; let opened_identity = opened_identity(&file, &lock_path, Some(&initial))?; if initial_identity != opened_identity { return Err(changed(&lock_path)); diff --git a/crates/codegraph-store/tests/index_lease.rs b/crates/codegraph-store/tests/index_lease.rs index 7135fd0..9c69ab1 100644 --- a/crates/codegraph-store/tests/index_lease.rs +++ b/crates/codegraph-store/tests/index_lease.rs @@ -24,6 +24,22 @@ static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); struct TempProject(PathBuf); +struct PermissionsGuard { + path: PathBuf, + original: std::fs::Permissions, +} + +impl Drop for PermissionsGuard { + fn drop(&mut self) { + std::fs::set_permissions(&self.path, self.original.clone()).unwrap_or_else(|error| { + panic!( + "restore permissions for read-only lock fixture {}: {error}", + self.path.display() + ) + }); + } +} + impl TempProject { fn new(label: &str) -> Self { let serial = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); @@ -73,6 +89,20 @@ fn lock_bytes(paths: &IndexPaths) -> Vec { std::fs::read(paths.permanent_lock()).expect("read permanent lock bytes") } +fn make_read_only(path: &Path) -> PermissionsGuard { + let original = std::fs::metadata(path) + .unwrap_or_else(|error| panic!("inspect lock permissions {}: {error}", path.display())) + .permissions(); + let mut read_only = original.clone(); + read_only.set_readonly(true); + std::fs::set_permissions(path, read_only) + .unwrap_or_else(|error| panic!("make lock read-only {}: {error}", path.display())); + PermissionsGuard { + path: path.to_path_buf(), + original, + } +} + /// Windows `ERROR_LOCK_VIOLATION`. /// /// A byte-range lock taken through `File::try_lock` is ADVISORY on Unix, so an @@ -277,6 +307,32 @@ fn explicit_initial_creation_is_separate_and_never_truncates_an_existing_lock() assert_eq!(lock_bytes(&paths), LOCK_BYTES); } +#[test] +fn shared_existing_open_needs_only_read_access_to_the_permanent_lock() { + let project = TempProject::new("shared-read-only-lock"); + let paths = project.paths(); + stage_existing_lock(&paths, LOCK_BYTES); + let _permissions = make_read_only(&paths.permanent_lock()); + + let write_error = OpenOptions::new() + .read(true) + .write(true) + .open(paths.permanent_lock()) + .expect_err("fixture must reject a write-capable lock handle"); + assert_eq!( + write_error.kind(), + std::io::ErrorKind::PermissionDenied, + "fixture must fail specifically because write access is denied" + ); + + let shared = IndexLease::acquire_shared_existing(&paths, deadline_after(CHILD_WAIT), || false) + .expect("shared lease must need only read access to the permanent lock"); + assert!(shared.is_shared()); + assert_lock_bytes_preserved(&paths, LOCK_BYTES); + drop(shared); + assert_eq!(lock_bytes(&paths), LOCK_BYTES); +} + #[test] fn shared_processes_coexist_but_shared_blocks_exclusive() { let project = TempProject::new("shared-matrix");