agent_first_http/sdk/profile/
lock.rs1use std::fs::OpenOptions;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10
11use fs2::FileExt;
12use serde::{Deserialize, Serialize};
13
14use crate::shared::error::{Error, ErrorCode};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct LockStatus {
18 pub locked: bool,
19 pub lockfile: PathBuf,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub owner_pid: Option<u32>,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub owner_started_at_rfc3339: Option<String>,
24}
25
26pub struct Guard {
30 file: std::fs::File,
31 profile_dir: PathBuf,
32}
33
34impl Guard {
35 pub fn acquire(profile_dir: &Path) -> Result<Self, Error> {
36 let path = lockfile(profile_dir);
37 let file = OpenOptions::new()
38 .read(true)
39 .write(true)
40 .create(true)
41 .truncate(false)
42 .open(&path)
43 .map_err(|e| {
44 Error::new(
45 ErrorCode::IoError,
46 format!("open lockfile {}: {e}", path.display()),
47 )
48 })?;
49 file.try_lock_exclusive().map_err(|e| {
50 Error::new(
51 ErrorCode::ProfileLocked,
52 format!("profile {} already locked: {e}", profile_dir.display()),
53 )
54 })?;
55 let pid_string = std::process::id().to_string();
59 let _ = atomic_overwrite(&pidfile(profile_dir), pid_string.as_bytes());
60 Ok(Self {
61 file,
62 profile_dir: profile_dir.to_path_buf(),
63 })
64 }
65}
66
67impl Drop for Guard {
68 fn drop(&mut self) {
69 let _ = FileExt::unlock(&self.file);
70 let _ = std::fs::remove_file(pidfile(&self.profile_dir));
73 }
74}
75
76fn atomic_overwrite(target: &Path, bytes: &[u8]) -> std::io::Result<()> {
77 let dir = target.parent().ok_or_else(|| {
78 std::io::Error::new(
79 std::io::ErrorKind::InvalidInput,
80 "target path has no parent",
81 )
82 })?;
83 let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
84 tmp.write_all(bytes)?;
85 tmp.as_file_mut().sync_all()?;
86 tmp.persist(target).map_err(|e| e.error)?;
87 Ok(())
88}
89
90pub fn probe(profile_dir: &Path) -> bool {
93 let path = lockfile(profile_dir);
94 if !path.exists() {
95 return false;
96 }
97 let file = match OpenOptions::new().read(true).write(true).open(&path) {
98 Ok(f) => f,
99 Err(_) => return false,
100 };
101 match file.try_lock_exclusive() {
102 Ok(()) => {
103 let _ = FileExt::unlock(&file);
104 false
105 }
106 Err(_) => true,
107 }
108}
109
110pub fn status(profile_dir: &Path) -> LockStatus {
111 let lockfile_path = lockfile(profile_dir);
112 let locked = probe(profile_dir);
113 let owner_pid = if locked {
114 std::fs::read_to_string(pidfile(profile_dir))
115 .ok()
116 .and_then(|s| s.trim().parse::<u32>().ok())
117 } else {
118 None
119 };
120 LockStatus {
121 locked,
122 lockfile: lockfile_path,
123 owner_pid,
124 owner_started_at_rfc3339: None,
125 }
126}
127
128fn lockfile(profile_dir: &Path) -> PathBuf {
129 profile_dir.join("afhttp-profile.lock")
130}
131
132fn pidfile(profile_dir: &Path) -> PathBuf {
133 profile_dir.join("afhttp-profile.pid")
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139
140 #[test]
141 fn acquire_and_drop_releases_lock() {
142 let tmp = tempfile::tempdir().unwrap();
143 let dir = tmp.path();
144 let guard = Guard::acquire(dir).unwrap();
145 assert!(probe(dir));
146 drop(guard);
147 assert!(!probe(dir));
148 }
149
150 #[test]
151 fn second_acquire_returns_profile_locked() {
152 let tmp = tempfile::tempdir().unwrap();
153 let dir = tmp.path();
154 let _g = Guard::acquire(dir).unwrap();
155 let err = Guard::acquire(dir).err().unwrap();
156 assert_eq!(err.error_code, ErrorCode::ProfileLocked);
157 }
158
159 #[test]
160 fn status_returns_owner_pid_when_locked() {
161 let tmp = tempfile::tempdir().unwrap();
162 let dir = tmp.path();
163 let _g = Guard::acquire(dir).unwrap();
164 let st = status(dir);
165 assert!(st.locked);
166 assert_eq!(st.owner_pid, Some(std::process::id()));
167 }
168
169 #[test]
170 fn pid_writes_are_atomic_under_concurrent_reads() {
171 let tmp = tempfile::tempdir().unwrap();
177 let dir = tmp.path().to_path_buf();
178 let expected_pid = std::process::id().to_string();
179 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
180
181 let writer_stop = stop.clone();
182 let writer_dir = dir.clone();
183 let writer = std::thread::spawn(move || {
184 while !writer_stop.load(std::sync::atomic::Ordering::Relaxed) {
185 let g = Guard::acquire(&writer_dir).unwrap();
186 drop(g);
187 }
188 });
189
190 let pid_path = pidfile(&dir);
191 let mut observed_pid = false;
192 let mut observed_absent = false;
193 for _ in 0..200 {
194 match std::fs::read(&pid_path) {
195 Ok(bytes) => {
196 let s = String::from_utf8_lossy(&bytes);
197 if s == expected_pid {
198 observed_pid = true;
199 } else {
200 panic!("non-atomic pidfile observed: {s:?}");
201 }
202 }
203 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
204 observed_absent = true;
205 }
206 Err(_) => {}
207 }
208 std::thread::sleep(std::time::Duration::from_micros(50));
209 }
210
211 stop.store(true, std::sync::atomic::Ordering::Relaxed);
212 writer.join().unwrap();
213
214 assert!(
215 observed_pid || observed_absent,
216 "expected at least one snapshot of the pidfile"
217 );
218 }
219}