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::other(format!(
76                "Cannot remove a dirty lock file, it is locked by another process (PID: {:#?})",
77                self.get_locker_pid()
78            )))
79        } else {
80            self.remove_file()?;
81            Ok(())
82        }
83    }
84
85    fn remove_file(&self) -> io::Result<()> {
86        match remove_file(&self.0) {
87            Err(e) => {
88                if e.kind() != std::io::ErrorKind::NotFound {
89                    return Err(e);
90                }
91                Ok(())
92            }
93            _ => Ok(()),
94        }
95    }
96
97    /// Returns the PID of the owner of the current lock. If the PID is not longer active the lock
98    /// file will be removed
99    pub fn get_locker_pid(&self) -> Option<usize> {
100        let fs = File::open(&self.0);
101        if let Ok(mut file) = fs {
102            let mut contents = String::new();
103            file.read_to_string(&mut contents).ok();
104            drop(file);
105            if let Ok(pid) = contents.trim().parse::<usize>() {
106                return if Self::is_pid_active(pid) {
107                    Some(pid)
108                } else {
109                    let _ = self.remove_file();
110                    None
111                };
112            }
113        }
114        None
115    }
116
117    /// Checks if the current path is owned by any other process. This will return false if there is
118    /// no lock file or the current process is the owner of the lock file
119    pub fn is_locked(&self) -> bool {
120        self.get_locker_pid()
121            .map(|pid| pid != (std::process::id() as usize))
122            .unwrap_or_default()
123    }
124
125    /// Locks the given filepath if it is not already locked
126    pub fn lock(&self) -> io::Result<()> {
127        self.release()?;
128        if let Some(dir) = self.0.parent() {
129            // Ensure the directory exists
130            create_dir_all(dir)?;
131        }
132
133        let mut fs = File::create(&self.0)?;
134        fs.write_all(std::process::id().to_string().as_bytes())?;
135        fs.sync_all()?;
136        fs.flush()?;
137        Ok(())
138    }
139
140    /// Cleans up all stale lock files in the .lsp-locks directory
141    /// Returns a vector of paths that were cleaned up
142    pub fn cleanup_stale_files() -> io::Result<Vec<PathBuf>> {
143        let lock_dir = user_forc_directory().join(".lsp-locks");
144        let entries = read_dir(&lock_dir)?;
145        let mut cleaned_paths = Vec::new();
146
147        for entry in entries {
148            let entry = entry?;
149            let path = entry.path();
150            if let Some(ext) = path.extension().and_then(|ext| ext.to_str()) {
151                if ext == "lock" {
152                    if let Ok(mut file) = File::open(&path) {
153                        let mut contents = String::new();
154                        if file.read_to_string(&mut contents).is_ok() {
155                            if let Ok(pid) = contents.trim().parse::<usize>() {
156                                if !Self::is_pid_active(pid) {
157                                    remove_file(&path)?;
158                                    cleaned_paths.push(path);
159                                }
160                            } else {
161                                remove_file(&path)?;
162                                cleaned_paths.push(path);
163                            }
164                        }
165                    }
166                }
167            }
168        }
169        Ok(cleaned_paths)
170    }
171}
172
173/// Checks if the specified file is marked as "dirty".
174/// This is used to prevent changing files that are currently open in an editor
175/// with unsaved changes.
176///
177/// Returns `true` if a corresponding "dirty" flag file exists, `false` otherwise.
178pub fn is_file_dirty<X: AsRef<Path>>(path: X) -> bool {
179    PidFileLocking::lsp(path.as_ref()).is_locked()
180}
181
182#[cfg(test)]
183mod test {
184    use super::{user_forc_directory, PidFileLocking};
185    use mark_flaky_tests::flaky;
186    use std::{
187        fs::{metadata, File},
188        io::{ErrorKind, Write},
189        os::unix::fs::MetadataExt,
190    };
191
192    #[test]
193    fn test_fs_locking_same_process() {
194        let x = PidFileLocking::lsp("test");
195        assert!(!x.is_locked()); // checks the non-existence of the lock (therefore it is not locked)
196        assert!(x.lock().is_ok());
197        // The current process is locking "test"
198        let x = PidFileLocking::lsp("test");
199        assert!(!x.is_locked());
200    }
201
202    #[test]
203    fn test_legacy() {
204        // tests against an empty file (as legacy were creating this files)
205        let x = PidFileLocking::lsp("legacy");
206        assert!(x.lock().is_ok());
207        // lock file exists,
208        assert!(metadata(&x.0).is_ok());
209
210        // simulate a stale lock file from legacy (which should be empty)
211        let _ = File::create(&x.0).unwrap();
212        assert_eq!(metadata(&x.0).unwrap().size(), 0);
213
214        let x = PidFileLocking::lsp("legacy");
215        assert!(!x.is_locked());
216    }
217
218    #[test]
219    fn test_remove() {
220        let x = PidFileLocking::lsp("lock");
221        assert!(x.lock().is_ok());
222        assert!(x.release().is_ok());
223        assert!(x.release().is_ok());
224    }
225
226    #[test]
227    fn test_fs_locking_stale() {
228        let x = PidFileLocking::lsp("stale");
229        assert!(x.lock().is_ok());
230
231        // lock file exists,
232        assert!(metadata(&x.0).is_ok());
233
234        // simulate a stale lock file
235        let mut x = File::create(&x.0).unwrap();
236        x.write_all(b"191919191919").unwrap();
237        x.flush().unwrap();
238        drop(x);
239
240        // PID=191919191919 does not exists, hopefully, and this should remove the lock file
241        let x = PidFileLocking::lsp("stale");
242        assert!(!x.is_locked());
243        let e = metadata(&x.0).unwrap_err().kind();
244        assert_eq!(e, ErrorKind::NotFound);
245    }
246
247    #[flaky]
248    #[test]
249    fn test_cleanup_stale_files() {
250        // First create some test files
251        let test_lock = PidFileLocking::lsp("test_cleanup");
252        test_lock.lock().expect("Failed to create test lock file");
253
254        // Create a test lock file with invalid PID
255        let lock_path = user_forc_directory()
256            .join(".lsp-locks")
257            .join("test_cleanup_invalid.lock");
258
259        // Write invalid content to ensure parsing fails
260        {
261            let mut file = File::create(&lock_path).expect("Failed to create test lock file");
262            file.write_all(b"not-a-pid")
263                .expect("Failed to write invalid content");
264            file.flush().expect("Failed to flush file");
265        }
266
267        // Verify both files exist before cleanup
268        assert!(
269            test_lock.0.exists(),
270            "Valid lock file should exist before cleanup"
271        );
272        assert!(
273            lock_path.exists(),
274            "Invalid lock file should exist before cleanup"
275        );
276
277        // Run cleanup and check returned paths
278        let cleaned_paths =
279            PidFileLocking::cleanup_stale_files().expect("Failed to cleanup stale files");
280
281        // Verify that only the invalid lock file was cleaned up
282        assert_eq!(cleaned_paths.len(), 1, "Expected one file to be cleaned up");
283        assert_eq!(
284            cleaned_paths[0], lock_path,
285            "Expected invalid file to be cleaned up"
286        );
287
288        // Verify file system state
289        assert!(test_lock.0.exists(), "Active lock file should still exist");
290        assert!(!lock_path.exists(), "Lock file should be removed");
291
292        // Cleanup after test
293        test_lock.release().expect("Failed to release test lock");
294    }
295}