Skip to main content

agent_first_http/sdk/profile/
lock.rs

1//! Advisory profile-directory locking via `fs2::FileExt::try_lock_exclusive`.
2//! The lockfile lives at `<profile>/afhttp-profile.lock` and is held for
3//! the lifetime of the [`Guard`]; release happens on `Drop`. The owner's
4//! PID lives in a sibling file `afhttp-profile.pid` so we can rewrite it
5//! atomically (rename-over) without invalidating the fs2 lock identity.
6
7use 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
26/// RAII guard around the profile lockfile. `acquire` opens or creates
27/// `<profile>/afhttp-profile.lock` and takes an exclusive advisory lock
28/// on it; the lock is released when the guard drops.
29pub 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        // Write our PID to a sibling file via tempfile + rename so a racing
56        // `status()` probe never reads a truncated PID. The lockfile itself
57        // is not touched — that preserves fs2 lock identity across rewrites.
58        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        // Best-effort cleanup of the PID file; the lockfile itself stays
71        // so probes continue to find it.
72        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
90/// Returns true if `profile_dir`'s lockfile is currently held by another
91/// process.
92pub 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        // While one thread repeatedly acquires + drops a Guard (writing the
172        // PID then deleting the pidfile on Drop), other threads poll the
173        // raw pidfile bytes. Without atomic rename the readers could catch
174        // a half-written PID; with it, every read must be either absent or
175        // the exact full PID string.
176        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}