1use std::{
9 fs,
10 io::{self, Seek, SeekFrom, Write},
11 path::{Path, PathBuf},
12 sync::atomic::{AtomicU64, Ordering},
13 time::{SystemTime, UNIX_EPOCH},
14};
15
16use fs2::FileExt;
17use serde::{Deserialize, Serialize};
18use thiserror::Error;
19
20use crate::{time::unix_now, watcher::ProcessIdentity};
21
22pub const STATE_LOCK_FILE: &str = "state.lock";
24static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
25
26#[derive(Debug, Error)]
27pub enum StateError {
28 #[error("state transaction IO failed: {0}")]
29 Io(#[from] io::Error),
30 #[error("state transaction lock JSON failed: {0}")]
31 Json(#[from] serde_json::Error),
32}
33
34#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
35struct StateLock {
36 identity: ProcessIdentity,
37 created_at_unix: u64,
38}
39
40#[derive(Debug)]
46pub struct StateTransaction {
47 lock_file: fs::File,
48}
49
50impl StateTransaction {
51 pub fn acquire(root: impl Into<PathBuf>) -> Result<Self, StateError> {
52 let root = root.into();
53 create_dir_all_durable(&root)?;
54 let path = root.join(STATE_LOCK_FILE);
55 let mut lock_file = fs::OpenOptions::new()
56 .create(true)
57 .truncate(true)
58 .read(true)
59 .write(true)
60 .open(&path)?;
61 lock_file.lock_exclusive()?;
62
63 let lock = StateLock {
64 identity: ProcessIdentity::current(),
65 created_at_unix: unix_now(),
66 };
67 let bytes = serde_json::to_vec_pretty(&lock)?;
68 lock_file.set_len(0)?;
69 lock_file.seek(SeekFrom::Start(0))?;
70 lock_file.write_all(&bytes)?;
71 lock_file.write_all(b"\n")?;
72 lock_file.sync_all()?;
73 sync_directory_best_effort(&root);
74 Ok(Self { lock_file })
75 }
76}
77
78impl Drop for StateTransaction {
79 fn drop(&mut self) {
80 let _ = FileExt::unlock(&self.lock_file);
81 }
82}
83
84pub fn atomic_replace(path: &Path, bytes: &[u8]) -> Result<(), StateError> {
90 let parent = path.parent().unwrap_or_else(|| Path::new("."));
91 create_dir_all_durable(parent)?;
92 let (temp_path, mut temp) = create_temp_file(path, parent)?;
93 let result = (|| -> Result<(), StateError> {
94 temp.write_all(bytes)?;
95 temp.sync_all()?;
96 drop(temp);
97 fs::rename(&temp_path, path)?;
98 sync_directory_best_effort(parent);
99 Ok(())
100 })();
101 if result.is_err() {
102 let _ = fs::remove_file(&temp_path);
103 }
104 result
105}
106
107fn create_temp_file(path: &Path, parent: &Path) -> Result<(PathBuf, fs::File), StateError> {
108 let file_name = path
109 .file_name()
110 .and_then(|name| name.to_str())
111 .unwrap_or("state");
112 loop {
113 let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
114 let nanos = SystemTime::now()
115 .duration_since(UNIX_EPOCH)
116 .map_or(0, |duration| duration.as_nanos());
117 let temp_path = parent.join(format!(
118 ".{file_name}.{}.{}.{}.tmp",
119 std::process::id(),
120 nanos,
121 sequence
122 ));
123 match fs::OpenOptions::new()
124 .write(true)
125 .create_new(true)
126 .open(&temp_path)
127 {
128 Ok(file) => return Ok((temp_path, file)),
129 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
130 Err(error) => return Err(error.into()),
131 }
132 }
133}
134
135fn create_dir_all_durable(path: &Path) -> Result<(), StateError> {
136 let mut missing = Vec::new();
137 let mut cursor = path;
138 while !cursor.exists() {
139 missing.push(cursor.to_path_buf());
140 let Some(parent) = cursor.parent() else {
141 break;
142 };
143 cursor = parent;
144 }
145
146 fs::create_dir_all(path)?;
147 for directory in missing.iter().rev() {
148 if let Some(parent) = directory.parent() {
149 sync_directory_best_effort(parent);
150 }
151 sync_directory_best_effort(directory);
152 }
153 Ok(())
154}
155
156pub(crate) fn sync_directory_best_effort(path: &Path) {
157 if let Ok(directory) = fs::File::open(path) {
158 let _ = directory.sync_all();
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use std::sync::{
165 Arc, Barrier,
166 atomic::{AtomicBool, Ordering},
167 mpsc,
168 };
169
170 use super::{STATE_LOCK_FILE, StateLock, StateTransaction, atomic_replace};
171 use crate::watcher::ProcessIdentity;
172
173 #[test]
174 fn state_lock_is_distinct_from_watcher_lock() {
175 let temp = tempfile::tempdir().unwrap();
176 std::fs::write(temp.path().join("watcher.lock"), b"occupied").unwrap();
177
178 let transaction = StateTransaction::acquire(temp.path()).unwrap();
179
180 assert!(temp.path().join(STATE_LOCK_FILE).exists());
181 drop(transaction);
182 assert!(temp.path().join("watcher.lock").exists());
183 }
184
185 #[test]
186 fn state_transactions_are_exclusive_within_one_process() {
187 let temp = tempfile::tempdir().unwrap();
188 let first = StateTransaction::acquire(temp.path()).unwrap();
189 let root = temp.path().to_path_buf();
190 let acquired = Arc::new(AtomicBool::new(false));
191 let acquired_in_thread = Arc::clone(&acquired);
192 let (attempting_tx, attempting_rx) = mpsc::channel();
193 let waiter = std::thread::spawn(move || {
194 attempting_tx.send(()).unwrap();
195 let second = StateTransaction::acquire(root).unwrap();
196 acquired_in_thread.store(true, Ordering::Release);
197 drop(second);
198 });
199
200 attempting_rx.recv().unwrap();
201 std::thread::sleep(std::time::Duration::from_millis(50));
202 assert!(!acquired.load(Ordering::Acquire));
203
204 drop(first);
205 waiter.join().unwrap();
206 assert!(acquired.load(Ordering::Acquire));
207 }
208
209 #[test]
210 fn stale_state_lock_identity_is_recovered() {
211 let temp = tempfile::tempdir().unwrap();
212 let stale = StateLock {
213 identity: ProcessIdentity {
214 pid: u32::MAX,
215 start_token: "dead".to_owned(),
216 },
217 created_at_unix: 0,
218 };
219 std::fs::write(
220 temp.path().join(STATE_LOCK_FILE),
221 serde_json::to_vec(&stale).unwrap(),
222 )
223 .unwrap();
224
225 let transaction = StateTransaction::acquire(temp.path()).unwrap();
226 let recovered: StateLock =
227 serde_json::from_slice(&std::fs::read(temp.path().join(STATE_LOCK_FILE)).unwrap())
228 .unwrap();
229
230 assert_eq!(recovered.identity.pid, std::process::id());
231 drop(transaction);
232 }
233
234 #[test]
235 fn atomic_replace_never_exposes_partial_bytes() {
236 let temp = tempfile::tempdir().unwrap();
237 let path = temp.path().join("nested").join("state.json");
238 let first = vec![b'a'; 128 * 1024];
239 let second = vec![b'b'; 128 * 1024];
240 atomic_replace(&path, &first).unwrap();
241
242 let barrier = Arc::new(Barrier::new(2));
243 let stop = Arc::new(AtomicBool::new(false));
244 let reader_path = path.clone();
245 let reader_barrier = Arc::clone(&barrier);
246 let reader_stop = Arc::clone(&stop);
247 let reader = std::thread::spawn(move || {
248 reader_barrier.wait();
249 while !reader_stop.load(Ordering::Acquire) {
250 let bytes = std::fs::read(&reader_path).unwrap();
251 assert!(
252 bytes.iter().all(|byte| *byte == b'a')
253 || bytes.iter().all(|byte| *byte == b'b')
254 );
255 assert_eq!(bytes.len(), 128 * 1024);
256 }
257 });
258
259 barrier.wait();
260 for _ in 0..20 {
261 atomic_replace(&path, &second).unwrap();
262 atomic_replace(&path, &first).unwrap();
263 }
264 stop.store(true, Ordering::Release);
265 reader.join().unwrap();
266
267 let temp_files = std::fs::read_dir(path.parent().unwrap())
268 .unwrap()
269 .filter_map(Result::ok)
270 .filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
271 .count();
272 assert_eq!(temp_files, 0);
273 }
274}