forc_util/
fs_locking.rs

1use crate::{hash_path, user_forc_directory};
2use std::{
3    fs::{create_dir_all, read_dir, remove_file, File},
4    io::{self, Read, Write},
5    path::{Path, PathBuf},
6};
7
8/// Very simple AdvisoryPathMutex class
9///
10/// The goal of this struct is to signal other processes that a path is being used by another
11/// process exclusively.
12///
13/// This struct will self-heal if the process that locked the file is no longer running.
14pub struct PidFileLocking(PathBuf);
15
16impl PidFileLocking {
17    pub fn new<X: AsRef<Path>, Y: AsRef<Path>>(
18        filename: X,
19        dir: Y,
20        extension: &str,
21    ) -> PidFileLocking {
22        // Try to cleanup stale files, ignore any errors as this is best-effort
23        let _ = Self::cleanup_stale_files();
24
25        let file_name = hash_path(filename);
26        Self(
27            user_forc_directory()
28                .join(dir)
29                .join(file_name)
30                .with_extension(extension),
31        )
32    }
33
34    /// Create a new PidFileLocking instance that is shared between the LSP and any other process
35    /// that may want to update the file and needs to wait for the LSP to finish (like forc-fmt)
36    pub fn lsp<X: AsRef<Path>>(filename: X) -> PidFileLocking {
37        Self::new(filename, ".lsp-locks", "lock")
38    }
39
40    /// Checks if the given pid is active
41    #[cfg(not(target_os = "windows"))]
42    fn is_pid_active(pid: usize) -> bool {
43        // Not using sysinfo here because it has compatibility issues with fuel.nix
44        // https://github.com/FuelLabs/fuel.nix/issues/64
45        use std::process::Command;
46        let output = Command::new("ps")
47            .arg("-p")
48            .arg(pid.to_string())
49            .output()
50            .expect("Failed to execute ps command");
51
52        let output_str = String::from_utf8_lossy(&output.stdout);
53        output_str.contains(&format!("{} ", pid))
54    }
55
56    #[cfg(target_os = "windows")]
57    fn is_pid_active(pid: usize) -> bool {
58        // Not using sysinfo here because it has compatibility issues with fuel.nix
59        // https://github.com/FuelLabs/fuel.nix/issues/64
60        use std::process::Command;
61        let output = Command::new("tasklist")
62            .arg("/FI")
63            .arg(format!("PID eq {}", pid))
64            .output()
65            .expect("Failed to execute tasklist command");
66
67        let output_str = String::from_utf8_lossy(&output.stdout);
68        // Check if the output contains the PID, indicating the process is active
69        output_str.contains(&format!("{}", pid))
70    }
71
72    /// Removes the lock file if it is not locked or the process that locked it is no longer active
73    pub fn release(&self) -> io::Result<()> {
74        if self.is_locked() {
75            Err(io::Error::new(
76                std::io::ErrorKind::Other,
77                format!(
78                    "Cannot remove a dirty lock file, it is locked by another process (PID: {:#?})",
79                    self.get_locker_pid()
80                ),
81            ))
82        } else {
83            self.remove_file()?;
84            Ok(())
85        }
86    }
87
88    fn remove_file(&self) -> io::Result<()> {
89        match remove_file(&self.0) {
90            Err(e) => {
91                if e.kind() != std::io::ErrorKind::NotFound {
92                    return Err(e);
93                }
94                Ok(())
95            }
96            _ => Ok(()),
97        }
98    }
99
100    /// Returns the PID of the owner of the current lock. If the PID is not longer active the lock
101    /// file will be removed
102    pub fn get_locker_pid(&self) -> Option<usize> {
103        let fs = File::open(&self.0);
104        if let Ok(mut file) = fs {
105            let mut contents = String::new();
106            file.read_to_string(&mut contents).ok();
107            drop(file);
108            if let Ok(pid) = contents.trim().parse::<usize>() {
109                return if Self::is_pid_active(pid) {
110                    Some(pid)
111                } else {
112                    let _ = self.remove_file();
113                    None
114                };
115            }
116        }
117        None
118    }
119
120    /// Checks if the current path is owned by any other process. This will return false if there is
121    /// no lock file or the current process is the owner of the lock file
122    pub fn is_locked(&self) -> bool {
123        self.get_locker_pid()
124            .map(|pid| pid != (std::process::id() as usize))
125            .unwrap_or_default()
126    }
127
128    /// Locks the given filepath if it is not already locked
129    pub fn lock(&self) -> io::Result<()> {
130        self.release()?;
131        if let Some(dir) = self.0.parent() {
132            // Ensure the directory exists
133            create_dir_all(dir)?;
134        }
135
136        let mut fs = File::create(&self.0)?;
137        fs.write_all(std::process::id().to_string().as_bytes())?;
138        fs.sync_all()?;
139        fs.flush()?;
140        Ok(())
141    }
142
143    /// Cleans up all stale lock files in the .lsp-locks directory
144    /// Returns a vector of paths that were cleaned up
145    pub fn cleanup_stale_files() -> io::Result<Vec<PathBuf>> {
146        let lock_dir = user_forc_directory().join(".lsp-locks");
147        let entries = read_dir(&lock_dir)?;
148        let mut cleaned_paths = Vec::new();
149
150        for entry in entries {
151            let entry = entry?;
152            let path = entry.path();
153            if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
154                if ext == "lock" {
155                    if let Ok(mut file) = File::open(&path) {
156                        let mut contents = String::new();
157                        if file.read_to_string(&mut contents).is_ok() {
158                            if let Ok(pid) = contents.trim().parse::<usize>() {
159                                if !Self::is_pid_active(pid) {
160                                    remove_file(&path)?;
161                                    cleaned_paths.push(path);
162                                }
163                            } else {
164                                remove_file(&path)?;
165                                cleaned_paths.push(path);
166                            }
167                        }
168                    }
169                }
170            }
171        }
172        Ok(cleaned_paths)
173    }
174}
175
176/// Checks if the specified file is marked as "dirty".
177/// This is used to prevent changing files that are currently open in an editor
178/// with unsaved changes.
179///
180/// Returns `true` if a corresponding "dirty" flag file exists, `false` otherwise.
181pub fn is_file_dirty<X: AsRef<Path>>(path: X) -> bool {
182    PidFileLocking::lsp(path.as_ref()).is_locked()
183}
184
185#[cfg(test)]
186mod test {
187    use super::{user_forc_directory, PidFileLocking};
188    use mark_flaky_tests::flaky;
189    use std::{
190        fs::{metadata, File},
191        io::{ErrorKind, Write},
192        os::unix::fs::MetadataExt,
193    };
194
195    #[test]
196    fn test_fs_locking_same_process() {
197        let x = PidFileLocking::lsp("test");
198        assert!(!x.is_locked()); // checks the non-existence of the lock (therefore it is not locked)
199        assert!(x.lock().is_ok());
200        // The current process is locking "test"
201        let x = PidFileLocking::lsp("test");
202        assert!(!x.is_locked());
203    }
204
205    #[test]
206    fn test_legacy() {
207        // tests against an empty file (as legacy were creating this files)
208        let x = PidFileLocking::lsp("legacy");
209        assert!(x.lock().is_ok());
210        // lock file exists,
211        assert!(metadata(&x.0).is_ok());
212
213        // simulate a stale lock file from legacy (which should be empty)
214        let _ = File::create(&x.0).unwrap();
215        assert_eq!(metadata(&x.0).unwrap().size(), 0);
216
217        let x = PidFileLocking::lsp("legacy");
218        assert!(!x.is_locked());
219    }
220
221    #[test]
222    fn test_remove() {
223        let x = PidFileLocking::lsp("lock");
224        assert!(x.lock().is_ok());
225        assert!(x.release().is_ok());
226        assert!(x.release().is_ok());
227    }
228
229    #[test]
230    fn test_fs_locking_stale() {
231        let x = PidFileLocking::lsp("stale");
232        assert!(x.lock().is_ok());
233
234        // lock file exists,
235        assert!(metadata(&x.0).is_ok());
236
237        // simulate a stale lock file
238        let mut x = File::create(&x.0).unwrap();
239        x.write_all(b"191919191919").unwrap();
240        x.flush().unwrap();
241        drop(x);
242
243        // PID=191919191919 does not exists, hopefully, and this should remove the lock file
244        let x = PidFileLocking::lsp("stale");
245        assert!(!x.is_locked());
246        let e = metadata(&x.0).unwrap_err().kind();
247        assert_eq!(e, ErrorKind::NotFound);
248    }
249
250    #[flaky]
251    #[test]
252    fn test_cleanup_stale_files() {
253        // First create some test files
254        let test_lock = PidFileLocking::lsp("test_cleanup");
255        test_lock.lock().expect("Failed to create test lock file");
256
257        // Create a test lock file with invalid PID
258        let lock_path = user_forc_directory()
259            .join(".lsp-locks")
260            .join("test_cleanup_invalid.lock");
261
262        // Write invalid content to ensure parsing fails
263        {
264            let mut file = File::create(&lock_path).expect("Failed to create test lock file");
265            file.write_all(b"not-a-pid")
266                .expect("Failed to write invalid content");
267            file.flush().expect("Failed to flush file");
268        }
269
270        // Verify both files exist before cleanup
271        assert!(
272            test_lock.0.exists(),
273            "Valid lock file should exist before cleanup"
274        );
275        assert!(
276            lock_path.exists(),
277            "Invalid lock file should exist before cleanup"
278        );
279
280        // Run cleanup and check returned paths
281        let cleaned_paths =
282            PidFileLocking::cleanup_stale_files().expect("Failed to cleanup stale files");
283
284        // Verify that only the invalid lock file was cleaned up
285        assert_eq!(cleaned_paths.len(), 1, "Expected one file to be cleaned up");
286        assert_eq!(
287            cleaned_paths[0], lock_path,
288            "Expected invalid file to be cleaned up"
289        );
290
291        // Verify file system state
292        assert!(test_lock.0.exists(), "Active lock file should still exist");
293        assert!(!lock_path.exists(), "Lock file should be removed");
294
295        // Cleanup after test
296        test_lock.release().expect("Failed to release test lock");
297    }
298}