forge_workspace/
single_instance.rs1use std::fs::{File, OpenOptions};
35use std::io::{Read, Seek, Write};
36use std::path::{Path, PathBuf};
37
38use rustix::fs::{FlockOperation, flock};
39
40const LOCK_DIR_NAME: &str = "locks";
43
44#[derive(Debug)]
46pub(crate) enum AcquireError {
47 AlreadyRunning { pid: Option<u32> },
51}
52
53fn lock_path(config_dir: &Path, app_support: &Path) -> PathBuf {
58 app_support.join(LOCK_DIR_NAME).join(format!("{}.lock", forge_sdk::config_dir_hash(config_dir)))
59}
60
61pub(crate) fn another_instance_running(config_dir: &Path, app_support: &Path) -> bool {
69 let ours = lock_path(config_dir, app_support);
70 let Ok(entries) = std::fs::read_dir(app_support.join(LOCK_DIR_NAME)) else { return false };
71 for entry in entries.flatten() {
72 let path = entry.path();
73 if path == ours || !path.is_file() {
74 continue;
75 }
76 let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) else {
79 return true;
80 };
81 match flock(&file, FlockOperation::NonBlockingLockExclusive) {
82 Ok(()) => {
83 let _ = flock(&file, FlockOperation::Unlock);
84 }
85 Err(_) => return true,
87 }
88 }
89 false
90}
91
92pub(crate) fn acquire(config_dir: &Path, app_support: &Path) -> Result<Option<File>, AcquireError> {
108 let path = lock_path(config_dir, app_support);
109 if let Some(parent) = path.parent()
110 && let Err(e) = std::fs::create_dir_all(parent)
111 {
112 tracing::warn!(
113 target: "forge_workspace::single_instance",
114 error = %e,
115 path = %parent.display(),
116 "lock dir create failed; single-instance guard skipped",
117 );
118 return Ok(None);
119 }
120 let mut file =
121 match OpenOptions::new().create(true).read(true).write(true).truncate(false).open(&path) {
122 Ok(f) => f,
123 Err(e) => {
124 tracing::warn!(
125 target: "forge_workspace::single_instance",
126 error = %e,
127 path = %path.display(),
128 "lockfile open failed; single-instance guard skipped",
129 );
130 return Ok(None);
131 }
132 };
133 match flock(&file, FlockOperation::NonBlockingLockExclusive) {
134 Ok(()) => {
135 record_pid(&mut file);
136 Ok(Some(file))
137 }
138 Err(e) if e == rustix::io::Errno::WOULDBLOCK || e == rustix::io::Errno::AGAIN => {
141 Err(AcquireError::AlreadyRunning { pid: read_pid(&mut file) })
142 }
143 Err(e) => {
144 tracing::warn!(
145 target: "forge_workspace::single_instance",
146 error = %e,
147 "flock on lockfile failed; single-instance guard skipped",
148 );
149 Ok(None)
150 }
151 }
152}
153
154fn record_pid(file: &mut File) {
159 let pid = std::process::id();
160 let _ = file.set_len(0);
161 let _ = file.rewind();
162 let _ = file.write_all(pid.to_string().as_bytes());
163 let _ = file.flush();
164}
165
166fn read_pid(file: &mut File) -> Option<u32> {
169 let mut buf = String::new();
170 file.read_to_string(&mut buf).ok()?;
171 buf.trim().parse().ok()
172}
173
174#[cfg(test)]
175mod tests {
176 use std::os::unix::fs::PermissionsExt as _;
177
178 use super::*;
179 use tempfile::tempdir;
180
181 fn base() -> tempfile::TempDir {
184 tempdir().expect("tempdir")
185 }
186
187 #[test]
188 fn lock_lives_under_locks_subdir_of_the_base() {
189 let cfg = tempdir().expect("cfg");
190 let base = base();
191 let _lock = acquire(cfg.path(), base.path()).expect("acquire ok").expect("holds the lock");
192 let path = lock_path(cfg.path(), base.path());
193 assert!(path.is_file(), "lock is a real file");
194 assert_eq!(
195 path.parent().and_then(Path::file_name).and_then(|s| s.to_str()),
196 Some("locks"),
197 "lock lives under <base>/locks/",
198 );
199 }
200
201 #[test]
202 fn distinct_config_dirs_get_distinct_locks_held_at_once() {
203 let base = base();
204 let cfg_a = tempdir().expect("cfg a");
205 let cfg_b = tempdir().expect("cfg b");
206 let a = acquire(cfg_a.path(), base.path()).expect("a ok").expect("a holds");
207 let b = acquire(cfg_b.path(), base.path()).expect("b ok").expect("b holds");
208 assert_ne!(
209 lock_path(cfg_a.path(), base.path()),
210 lock_path(cfg_b.path(), base.path()),
211 "different config dirs hash to different lock files",
212 );
213 drop((a, b));
216 }
217
218 #[test]
219 fn acquire_succeeds_on_fresh_dir_and_records_pid() {
220 let cfg = tempdir().expect("cfg");
221 let base = base();
222 let lock = acquire(cfg.path(), base.path()).expect("acquire ok");
223 assert!(lock.is_some(), "a fresh config dir acquires the lock");
224
225 let contents =
226 std::fs::read_to_string(lock_path(cfg.path(), base.path())).expect("read lock");
227 assert_eq!(
228 contents.trim().parse::<u32>().expect("pid parses"),
229 std::process::id(),
230 "the lockfile records our PID",
231 );
232 }
233
234 #[test]
235 fn second_acquire_reports_already_running_with_pid() {
236 let cfg = tempdir().expect("cfg");
237 let base = base();
238 let _first = acquire(cfg.path(), base.path()).expect("first ok").expect("first holds");
239
240 match acquire(cfg.path(), base.path()) {
241 Err(AcquireError::AlreadyRunning { pid }) => {
242 assert_eq!(pid, Some(std::process::id()), "refusal names the holder's PID");
243 }
244 other => panic!("expected AlreadyRunning, got {other:?}"),
245 }
246 }
249
250 #[test]
251 fn lock_releases_on_drop_allowing_reacquire() {
252 let cfg = tempdir().expect("cfg");
253 let base = base();
254 {
255 let _first = acquire(cfg.path(), base.path()).expect("first ok").expect("first holds");
256 }
257 let second = acquire(cfg.path(), base.path()).expect("second ok");
258 assert!(second.is_some(), "the lock is reacquirable after the holder drops it");
259 }
260
261 #[test]
264 fn another_instance_running_sees_only_a_held_peer_lock() {
265 let base = base();
266 let cfg_a = tempdir().expect("cfg a");
267 let cfg_b = tempdir().expect("cfg b");
268
269 let stale = lock_path(cfg_b.path(), base.path());
271 std::fs::create_dir_all(stale.parent().expect("locks dir")).expect("locks dir");
272 std::fs::write(&stale, b"12345").expect("stale lock");
273 assert!(
274 !another_instance_running(cfg_a.path(), base.path()),
275 "an exited process's lockfile is not a live peer",
276 );
277
278 let held = acquire(cfg_b.path(), base.path()).expect("b acquires").expect("b holds");
279 assert!(
280 another_instance_running(cfg_a.path(), base.path()),
281 "a held peer lock means another forge is live",
282 );
283 assert!(
284 !another_instance_running(cfg_b.path(), base.path()),
285 "a server never counts its own lock as a peer",
286 );
287 drop(held);
288 assert!(
289 !another_instance_running(cfg_a.path(), base.path()),
290 "the peer is gone once its lock drops",
291 );
292
293 let locked = lock_path(tempdir().expect("cfg c").path(), base.path());
296 std::fs::create_dir_all(locked.parent().expect("locks dir")).expect("locks dir");
297 std::fs::write(&locked, b"12345").expect("the unreadable lock");
298 let mut perms = std::fs::metadata(&locked).expect("metadata").permissions();
299 perms.set_mode(0o000);
300 std::fs::set_permissions(&locked, perms).expect("make it unreadable");
301 assert!(
302 another_instance_running(cfg_a.path(), base.path()),
303 "an unopenable lock reads as live",
304 );
305 }
306}