Skip to main content

forge_workspace/
single_instance.rs

1//! Single-instance boot guard.
2//!
3//! forge guarantees one instance per config dir. [`crate::Workspace::new`]
4//! calls [`acquire`] at startup, taking a non-blocking exclusive `flock`
5//! on a never-renamed lockfile. If another forge already holds it, boot
6//! refuses with an [`AcquireError::AlreadyRunning`]: a clean stderr
7//! message + non-zero exit, not a panic. Otherwise the caller's PID is
8//! written and the lock is held for the process lifetime (flock
9//! auto-releases on exit/crash, so there is no stale-lock cleanup). One
10//! instance per config dir is what lets the rest of forge - the cron
11//! store especially - coordinate with just an in-process mutex instead
12//! of cross-process locking.
13//!
14//! The lockfile is MACHINE-LOCAL: it lives under forge's app-support base
15//! ([`forge_sdk::app_support_dir`], beside the logs) at
16//! `locks/<hash>.lock`, where `<hash>` is derived from the config-dir
17//! path so distinct profiles / config dirs on one machine get distinct,
18//! independently-held locks.
19//!
20//! Why there and not beside the other forge state in `<config_dir>/forge/`:
21//! `flock` binds the open file description (the inode), not the path, and
22//! rewriting a file by renaming a temp over it swaps the inode, so the lock
23//! must be a dedicated file that is only ever opened + flocked, never
24//! renamed. The config dir is Syncthing-synced across the user's Macs, and
25//! Syncthing applies incoming changes by rename - a lock synced in from
26//! another Mac would swap the inode out from under a running forge,
27//! orphaning its flock and letting a second instance start. A machine-local
28//! lock outside the synced dir sidesteps it.
29//!
30//! A v0.18.0 boot may have left a stray `<config_dir>/forge/forge.lock`; it
31//! is now unused and harmless (a fresh boot writes the machine-local lock
32//! instead), so there is no migration.
33
34use std::fs::{File, OpenOptions};
35use std::io::{Read, Seek, Write};
36use std::path::{Path, PathBuf};
37
38use rustix::fs::{FlockOperation, flock};
39
40/// Subdirectory of the app-support base that holds the per-config-dir
41/// lockfiles.
42const LOCK_DIR_NAME: &str = "locks";
43
44/// Why [`acquire`] refused.
45#[derive(Debug)]
46pub(crate) enum AcquireError {
47    /// A live forge already owns this config dir. `pid` is the holder's
48    /// PID read from the lockfile (`None` if the file was empty or
49    /// couldn't be parsed).
50    AlreadyRunning { pid: Option<u32> },
51}
52
53/// Machine-local lock path for `config_dir` under `app_support`:
54/// `<app_support>/locks/<hex-hash-of-config-dir>.lock`. The config-dir
55/// path is canonicalised first (best-effort) so symlinked / trailing-slash
56/// variants resolve to one lock.
57fn 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
61/// Whether another live forge holds a lock under `app_support` for a config
62/// dir other than `config_dir`.
63///
64/// **The machine-wide app-support tree is shared by every config dir**, so a
65/// boot that changes something there - the browser host's per-version prune -
66/// has to ask whether a peer is still running first. A lock file whose flock
67/// nobody holds is a leftover from a process that exited, not a peer.
68pub(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        // A lockfile that cannot be opened cannot be shown to be free:
77        // treat it as a live peer rather than step over it.
78        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            // Someone holds it: a live forge owns that config dir.
86            Err(_) => return true,
87        }
88    }
89    false
90}
91
92/// Acquire the per-config-dir single-instance lock under `app_support`.
93///
94/// The caller resolves the base, so a test can pass a tempdir and never
95/// write to the real app-support directory. In production that base is
96/// [`forge_sdk::app_support_dir`], which keeps the lock out of the
97/// Syncthing-synced config dir (see the module docs).
98///
99/// On success the returned `File` MUST be held for the process lifetime;
100/// dropping it (or process exit / crash) releases the flock, so there is
101/// no stale-lock cleanup. `Ok(None)` is the degraded path: the lock dir
102/// couldn't be created, the lockfile couldn't be opened, or the
103/// filesystem rejected `flock`, so forge boots without the guarantee
104/// rather than refusing over an exotic FS (the local APFS/ext4 the user
105/// runs always supports flock). `Err` means another forge instance
106/// already owns this config dir.
107pub(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        // EWOULDBLOCK / EAGAIN (same value on Unix) is the contended
139        // case: another process holds the exclusive lock.
140        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
154/// Stamp our PID into the (now exclusively-held) lockfile so a would-be
155/// second instance can name us in its refusal. Best-effort: a failed
156/// write doesn't void the lock - the flock is what enforces
157/// single-instance; the PID is only for the message.
158fn 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
166/// Read the holder's PID from the lockfile. `None` when empty or
167/// unparseable.
168fn 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    /// A tempdir standing in for the machine-local app-support base, so
182    /// tests never write to the real `~/Library/Application Support`.
183    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        // Both locks are held simultaneously under the same base -
214        // one config dir's lock never contends with another's.
215        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        // `_first` stays held across the second acquire and releases at
247        // end of scope.
248    }
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    /// **A peer's live lock is what a boot must not step over.** A floor of
262    /// stale lockfiles from exited processes is not a peer; a held one is.
263    #[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        // A stale file from a process that exited: present, not held.
270        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        // A lock that cannot be opened cannot be shown to be free, so it is
294        // treated as a live peer rather than stepped over.
295        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}