1use solo_core::{Error, Result};
26use std::fs::{File, OpenOptions};
27use std::io::Write;
28use std::path::{Path, PathBuf};
29use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
30
31fn is_pid_alive(pid: u32) -> bool {
35 let sys = System::new_with_specifics(
37 RefreshKind::new().with_processes(ProcessRefreshKind::new()),
38 );
39 sys.process(Pid::from_u32(pid)).is_some()
40}
41
42#[derive(Debug)]
44pub struct Lockfile {
45 path: PathBuf,
46 _handle: File,
49}
50
51impl Lockfile {
52 pub fn acquire(path: &Path) -> Result<Self> {
57 match Self::try_create(path) {
58 Ok(lf) => Ok(lf),
59 Err(Error::Conflict(_)) => {
60 Self::try_recover_stale(path)?;
62 Self::try_create(path)
64 }
65 Err(e) => Err(e),
66 }
67 }
68
69 fn try_recover_stale(path: &Path) -> Result<()> {
72 let body = match std::fs::read_to_string(path) {
73 Ok(s) => s,
74 Err(_) => {
75 return Err(Self::held_error(path, None));
77 }
78 };
79 let pid = body.trim().parse::<u32>().ok();
80 let alive = match pid {
81 Some(p) => is_pid_alive(p),
82 None => false,
85 };
86 if alive {
87 return Err(Self::held_error(path, pid));
88 }
89 tracing::warn!(
91 ?pid,
92 path = %path.display(),
93 "stale lockfile detected (pid not alive); removing"
94 );
95 std::fs::remove_file(path)
96 .map_err(|e| Error::storage(format!("remove stale lockfile {}: {e}", path.display())))?;
97 Ok(())
98 }
99
100 fn try_create(path: &Path) -> Result<Self> {
101 let mut handle = OpenOptions::new()
102 .write(true)
103 .create_new(true)
104 .open(path)
105 .map_err(|e| match e.kind() {
106 std::io::ErrorKind::AlreadyExists => Self::held_error(path, None),
107 _ => Error::storage(format!("open lockfile {}: {e}", path.display())),
108 })?;
109 let pid = std::process::id();
110 write!(handle, "{pid}")
111 .map_err(|e| Error::storage(format!("write pid to lockfile: {e}")))?;
112 handle
113 .sync_all()
114 .map_err(|e| Error::storage(format!("fsync lockfile: {e}")))?;
115 Ok(Self {
116 path: path.to_path_buf(),
117 _handle: handle,
118 })
119 }
120
121 fn held_error(path: &Path, pid: Option<u32>) -> Error {
122 let pid_msg = match pid {
123 Some(p) => format!(" (held by pid {p})"),
124 None => String::new(),
125 };
126 Error::conflict(format!(
127 "lockfile {} already exists{pid_msg} — another Solo process is \
128 running. If you're sure no other instance is alive, remove the \
129 file manually.",
130 path.display()
131 ))
132 }
133
134 pub fn path(&self) -> &Path {
136 &self.path
137 }
138}
139
140impl Drop for Lockfile {
141 fn drop(&mut self) {
142 if let Err(e) = std::fs::remove_file(&self.path) {
145 tracing::warn!(
146 error = %e,
147 path = %self.path.display(),
148 "failed to remove lockfile on drop"
149 );
150 }
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use tempfile::TempDir;
158
159 #[test]
160 fn acquire_creates_file_with_pid() {
161 let tmp = TempDir::new().unwrap();
162 let path = tmp.path().join("solo.lock");
163 let _lock = Lockfile::acquire(&path).unwrap();
164 assert!(path.exists());
165 let body = std::fs::read_to_string(&path).unwrap();
166 let pid: u32 = body.parse().expect("pid should be a number");
167 assert_eq!(pid, std::process::id());
168 }
169
170 #[test]
171 fn second_acquire_fails_with_conflict() {
172 let tmp = TempDir::new().unwrap();
173 let path = tmp.path().join("solo.lock");
174 let _lock = Lockfile::acquire(&path).unwrap();
175 let err = Lockfile::acquire(&path).unwrap_err();
176 assert!(matches!(err, Error::Conflict(_)), "got: {err:?}");
177 }
178
179 #[test]
180 fn drop_removes_file() {
181 let tmp = TempDir::new().unwrap();
182 let path = tmp.path().join("solo.lock");
183 {
184 let _lock = Lockfile::acquire(&path).unwrap();
185 assert!(path.exists());
186 }
187 assert!(!path.exists(), "lockfile should be removed on drop");
188 }
189
190 #[test]
191 fn re_acquire_after_drop_succeeds() {
192 let tmp = TempDir::new().unwrap();
193 let path = tmp.path().join("solo.lock");
194 {
195 let _lock = Lockfile::acquire(&path).unwrap();
196 }
197 let _lock2 = Lockfile::acquire(&path).unwrap();
198 }
199
200 #[test]
201 fn stale_lockfile_with_dead_pid_is_recovered() {
202 let tmp = TempDir::new().unwrap();
203 let path = tmp.path().join("solo.lock");
204 std::fs::write(&path, format!("{}", u32::MAX)).unwrap();
209 let lock = Lockfile::acquire(&path).unwrap();
212 assert!(path.exists());
213 let body = std::fs::read_to_string(&path).unwrap();
214 let pid: u32 = body.trim().parse().unwrap();
215 assert_eq!(pid, std::process::id());
216 drop(lock);
217 }
218
219 #[test]
220 fn stale_lockfile_with_unparseable_body_is_recovered() {
221 let tmp = TempDir::new().unwrap();
222 let path = tmp.path().join("solo.lock");
223 std::fs::write(&path, b"<garbage from a partial write>").unwrap();
224 let _lock = Lockfile::acquire(&path).unwrap();
225 }
227
228 #[test]
229 fn live_pid_is_not_recovered() {
230 let tmp = TempDir::new().unwrap();
231 let path = tmp.path().join("solo.lock");
232 std::fs::write(&path, format!("{}", std::process::id())).unwrap();
234 let err = Lockfile::acquire(&path).unwrap_err();
235 assert!(matches!(err, Error::Conflict(_)), "got: {err:?}");
236 assert!(path.exists());
238 }
239}