lock.rs (10709B)
1 //! SQLite process and writer locking boundary. 2 3 use crate::{Error, OpenMode, Paths}; 4 use fs2::FileExt; 5 use std::fs::{self, File, OpenOptions}; 6 use std::path::{Path, PathBuf}; 7 8 const WRITER_LOCK_FILE_NAME: &str = ".radroots-storage-writer.lock"; 9 10 /// Lifetime guard for the one writable backend permitted for an owned store. 11 /// 12 /// The persistent empty sidecar is never removed during normal operation. 13 /// Keeping one inode avoids an unlink/recreate race between competing clients; 14 /// dropping the file descriptor releases the operating-system advisory lock. 15 pub(crate) struct WriterLock { 16 file: File, 17 path: PathBuf, 18 } 19 20 impl WriterLock { 21 /// Acquires the governed lock for writable modes without hidden waiting. 22 /// Read-only clients deliberately acquire no advisory lock. 23 #[cfg_attr(coverage_nightly, coverage(off))] 24 pub(crate) fn acquire(paths: &Paths, mode: OpenMode) -> Result<Option<Self>, Error> { 25 if !mode.is_writable() { 26 return Ok(None); 27 } 28 let path = writer_lock_path(paths)?; 29 let file = open_lock_file(&path)?; 30 match FileExt::try_lock_exclusive(&file) { 31 Ok(()) => Ok(Some(Self { file, path })), 32 Err(source) if source.kind() == std::io::ErrorKind::WouldBlock => { 33 Err(Error::WriterAlreadyActive { path }) 34 } 35 Err(source) => Err(Error::WriterLockFailed { path, source }), 36 } 37 } 38 39 /// Explicitly releases the writer lock for the later asynchronous close 40 /// lifecycle. Dropping the guard remains a fail-safe release path. 41 #[cfg_attr(coverage_nightly, coverage(off))] 42 pub(crate) fn release(self) -> Result<(), Error> { 43 FileExt::unlock(&self.file).map_err(|source| Error::WriterUnlockFailed { 44 path: self.path.clone(), 45 source, 46 }) 47 } 48 49 #[cfg(test)] 50 fn path(&self) -> &Path { 51 &self.path 52 } 53 } 54 55 #[cfg_attr(coverage_nightly, coverage(off))] 56 fn writer_lock_path(paths: &Paths) -> Result<PathBuf, Error> { 57 let parent = paths 58 .runtime() 59 .parent() 60 .ok_or_else(|| Error::InvalidPath(paths.runtime().to_path_buf()))?; 61 let canonical_parent = fs::canonicalize(parent).map_err(|source| Error::Inspect { 62 path: parent.to_path_buf(), 63 source, 64 })?; 65 Ok(canonical_parent.join(WRITER_LOCK_FILE_NAME)) 66 } 67 68 #[cfg_attr(coverage_nightly, coverage(off))] 69 fn open_lock_file(path: &Path) -> Result<File, Error> { 70 match create_lock_file(path) { 71 Ok(file) => validate_open_file(path, file), 72 Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => { 73 let metadata = fs::symlink_metadata(path).map_err(|source| Error::WriterLockOpen { 74 path: path.to_path_buf(), 75 source, 76 })?; 77 if metadata.file_type().is_symlink() { 78 return Err(Error::SymlinkPath(path.to_path_buf())); 79 } 80 if !metadata.is_file() { 81 return Err(Error::NotAFile(path.to_path_buf())); 82 } 83 let file = OpenOptions::new() 84 .read(true) 85 .write(true) 86 .open(path) 87 .map_err(|source| Error::WriterLockOpen { 88 path: path.to_path_buf(), 89 source, 90 })?; 91 validate_open_file(path, file) 92 } 93 Err(source) => Err(Error::WriterLockOpen { 94 path: path.to_path_buf(), 95 source, 96 }), 97 } 98 } 99 100 #[cfg_attr(coverage_nightly, coverage(off))] 101 fn create_lock_file(path: &Path) -> std::io::Result<File> { 102 let mut options = OpenOptions::new(); 103 options.create_new(true).read(true).write(true); 104 #[cfg(unix)] 105 { 106 use std::os::unix::fs::OpenOptionsExt; 107 options.mode(0o600); 108 } 109 options.open(path) 110 } 111 112 #[cfg_attr(coverage_nightly, coverage(off))] 113 fn validate_open_file(path: &Path, file: File) -> Result<File, Error> { 114 let metadata = file.metadata().map_err(|source| Error::WriterLockOpen { 115 path: path.to_path_buf(), 116 source, 117 })?; 118 if !metadata.is_file() { 119 return Err(Error::NotAFile(path.to_path_buf())); 120 } 121 Ok(file) 122 } 123 124 #[cfg(test)] 125 mod tests { 126 use super::*; 127 use serde::Deserialize; 128 129 const POLICY: &str = include_str!("../../../contracts/storage/writer_policy_v1.toml"); 130 131 #[derive(Deserialize)] 132 struct Policy { 133 schema_version: u32, 134 lock_file_name: String, 135 lock_scope: String, 136 acquisition: String, 137 sidecar_lifecycle: String, 138 writable_modes: Vec<String>, 139 read_only_lock: String, 140 concurrent_readers: bool, 141 reader_with_writer: bool, 142 concurrent_writers: bool, 143 } 144 145 fn paths(directory: &Path) -> Paths { 146 Paths::from_directory(directory).expect("owned paths") 147 } 148 149 #[test] 150 fn implementation_matches_the_governed_multi_client_policy() { 151 let policy = toml::from_str::<Policy>(POLICY).expect("writer policy"); 152 assert_eq!(policy.schema_version, 1); 153 assert_eq!(policy.lock_file_name, WRITER_LOCK_FILE_NAME); 154 assert_eq!(policy.lock_scope, "canonical_runtime_parent"); 155 assert_eq!(policy.acquisition, "non_blocking_exclusive_advisory"); 156 assert_eq!(policy.sidecar_lifecycle, "persistent_empty_file"); 157 assert_eq!(policy.writable_modes, ["read_write_existing", "create"]); 158 assert_eq!(policy.read_only_lock, "none"); 159 assert!(policy.concurrent_readers); 160 assert!(policy.reader_with_writer); 161 assert!(!policy.concurrent_writers); 162 } 163 164 #[test] 165 fn one_writer_excludes_other_clients_until_explicit_release() { 166 let directory = tempfile::tempdir().expect("temporary directory"); 167 let paths = paths(directory.path()); 168 let first = WriterLock::acquire(&paths, OpenMode::Create) 169 .expect("first writer") 170 .expect("writer guard"); 171 assert_eq!(first.path(), directory.path().join(WRITER_LOCK_FILE_NAME)); 172 assert!(first.path().is_file()); 173 assert!(matches!( 174 WriterLock::acquire(&paths, OpenMode::ReadWriteExisting), 175 Err(Error::WriterAlreadyActive { .. }) 176 )); 177 178 first.release().expect("release first writer"); 179 let next = WriterLock::acquire(&paths, OpenMode::ReadWriteExisting) 180 .expect("next writer") 181 .expect("writer guard"); 182 drop(next); 183 assert!( 184 directory.path().join(WRITER_LOCK_FILE_NAME).is_file(), 185 "the stable sidecar must not be unlinked" 186 ); 187 } 188 189 #[test] 190 fn read_only_clients_take_no_lock_and_can_coexist_with_a_writer() { 191 let directory = tempfile::tempdir().expect("temporary directory"); 192 let paths = paths(directory.path()); 193 let writer = WriterLock::acquire(&paths, OpenMode::Create) 194 .expect("writer") 195 .expect("writer guard"); 196 assert!( 197 WriterLock::acquire(&paths, OpenMode::ReadOnly) 198 .expect("first reader") 199 .is_none() 200 ); 201 assert!( 202 WriterLock::acquire(&paths, OpenMode::ReadOnly) 203 .expect("second reader") 204 .is_none() 205 ); 206 drop(writer); 207 } 208 209 #[test] 210 fn writer_lock_is_observed_by_a_distinct_process() { 211 let directory = tempfile::tempdir().expect("temporary directory"); 212 let paths = paths(directory.path()); 213 let writer = WriterLock::acquire(&paths, OpenMode::Create) 214 .expect("writer") 215 .expect("writer guard"); 216 let status = std::process::Command::new(std::env::current_exe().expect("test executable")) 217 .arg("--exact") 218 .arg("lock::tests::writer_lock_child_probe") 219 .arg("--nocapture") 220 .env("RADROOTS_WRITER_LOCK_CHILD_PATH", directory.path()) 221 .status() 222 .expect("run lock child"); 223 assert!(status.success(), "child must observe writer contention"); 224 drop(writer); 225 } 226 227 #[test] 228 fn writer_lock_child_probe() { 229 let Some(directory) = std::env::var_os("RADROOTS_WRITER_LOCK_CHILD_PATH") else { 230 return; 231 }; 232 assert!(matches!( 233 WriterLock::acquire(&paths(Path::new(&directory)), OpenMode::Create), 234 Err(Error::WriterAlreadyActive { .. }) 235 )); 236 } 237 238 #[test] 239 fn stores_in_distinct_canonical_directories_do_not_contend() { 240 let first_directory = tempfile::tempdir().expect("first directory"); 241 let second_directory = tempfile::tempdir().expect("second directory"); 242 let first = WriterLock::acquire(&paths(first_directory.path()), OpenMode::Create) 243 .expect("first writer") 244 .expect("first guard"); 245 let second = WriterLock::acquire(&paths(second_directory.path()), OpenMode::Create) 246 .expect("second writer") 247 .expect("second guard"); 248 assert_ne!(first.path(), second.path()); 249 } 250 251 #[cfg(unix)] 252 #[test] 253 fn symlink_lock_files_fail_closed() { 254 use std::os::unix::fs::symlink; 255 256 let directory = tempfile::tempdir().expect("temporary directory"); 257 let target = directory.path().join("unrelated"); 258 fs::write(&target, []).expect("target"); 259 symlink(&target, directory.path().join(WRITER_LOCK_FILE_NAME)).expect("lock symlink"); 260 assert!(matches!( 261 WriterLock::acquire(&paths(directory.path()), OpenMode::Create), 262 Err(Error::SymlinkPath(_)) 263 )); 264 } 265 266 #[cfg(unix)] 267 #[test] 268 fn canonical_parent_aliases_share_one_writer_lock() { 269 use std::os::unix::fs::symlink; 270 271 let root = tempfile::tempdir().expect("temporary root"); 272 let actual = root.path().join("actual"); 273 let alias = root.path().join("alias"); 274 fs::create_dir(&actual).expect("actual directory"); 275 symlink(&actual, &alias).expect("directory alias"); 276 let writer = WriterLock::acquire(&paths(&actual), OpenMode::Create) 277 .expect("writer") 278 .expect("writer guard"); 279 assert!(matches!( 280 WriterLock::acquire(&paths(&alias), OpenMode::Create), 281 Err(Error::WriterAlreadyActive { .. }) 282 )); 283 drop(writer); 284 } 285 286 #[test] 287 fn non_file_lock_paths_fail_closed() { 288 let directory = tempfile::tempdir().expect("temporary directory"); 289 fs::create_dir(directory.path().join(WRITER_LOCK_FILE_NAME)).expect("lock directory"); 290 assert!(matches!( 291 WriterLock::acquire(&paths(directory.path()), OpenMode::Create), 292 Err(Error::NotAFile(_)) 293 )); 294 } 295 }