Skip to main content

uv_fs/
locked_file.rs

1use std::convert::Into;
2use std::fmt::Display;
3use std::path::{Path, PathBuf};
4use std::sync::LazyLock;
5use std::time::Duration;
6use std::{env, io};
7
8use thiserror::Error;
9use tracing::{debug, error, info, trace, warn};
10
11use uv_static::EnvVars;
12
13use crate::{Simplified, is_known_already_locked_error};
14
15/// Parsed value of `UV_LOCK_TIMEOUT`, with a default of 5 min.
16static LOCK_TIMEOUT: LazyLock<Duration> = LazyLock::new(|| {
17    let default_timeout = Duration::from_mins(5);
18    let Some(lock_timeout) = env::var_os(EnvVars::UV_LOCK_TIMEOUT) else {
19        return default_timeout;
20    };
21
22    if let Some(lock_timeout) = lock_timeout
23        .to_str()
24        .and_then(|lock_timeout| lock_timeout.parse::<u64>().ok())
25    {
26        Duration::from_secs(lock_timeout)
27    } else {
28        warn!(
29            "Could not parse value of {} as integer: {:?}",
30            EnvVars::UV_LOCK_TIMEOUT,
31            lock_timeout
32        );
33        default_timeout
34    }
35});
36
37#[derive(Debug, Error)]
38pub enum LockedFileError {
39    #[error(
40        "Timeout ({}s) when waiting for lock on `{}` at `{}`, is another uv process running? You can set `{}` to increase the timeout.",
41        timeout.as_secs(),
42        resource,
43        path.user_display(),
44        EnvVars::UV_LOCK_TIMEOUT
45    )]
46    Timeout {
47        timeout: Duration,
48        resource: String,
49        path: PathBuf,
50    },
51    #[error(
52        "Could not acquire lock for `{}` at `{}`",
53        resource,
54        path.user_display()
55    )]
56    Lock {
57        resource: String,
58        path: PathBuf,
59        #[source]
60        source: io::Error,
61    },
62    #[error(transparent)]
63    #[cfg(feature = "tokio")]
64    JoinError(#[from] tokio::task::JoinError),
65    #[error("Could not create temporary file")]
66    CreateTemporary(#[source] io::Error),
67    #[error("Could not persist temporary file `{}`", path.user_display())]
68    PersistTemporary {
69        path: PathBuf,
70        #[source]
71        source: io::Error,
72    },
73    #[error(transparent)]
74    Io(#[from] io::Error),
75}
76
77impl LockedFileError {
78    pub fn as_io_error(&self) -> Option<&io::Error> {
79        match self {
80            Self::Timeout { .. } => None,
81            #[cfg(feature = "tokio")]
82            Self::JoinError(_) => None,
83            Self::Lock { source, .. } => Some(source),
84            Self::CreateTemporary(err) => Some(err),
85            Self::PersistTemporary { source, .. } => Some(source),
86            Self::Io(err) => Some(err),
87        }
88    }
89}
90
91/// Whether to acquire a shared (read) lock or exclusive (write) lock.
92#[derive(Debug, Clone, Copy)]
93pub enum LockedFileMode {
94    Shared,
95    Exclusive,
96}
97
98impl LockedFileMode {
99    /// Try to lock the file and return an error if the lock is already acquired by another process
100    /// and cannot be acquired immediately.
101    fn try_lock(self, file: &fs_err::File) -> Result<(), std::fs::TryLockError> {
102        match self {
103            Self::Exclusive => file.try_lock()?,
104            Self::Shared => file.try_lock_shared()?,
105        }
106        Ok(())
107    }
108
109    /// Lock the file, blocking until the lock becomes available if necessary.
110    fn lock(self, file: &fs_err::File) -> Result<(), io::Error> {
111        match self {
112            Self::Exclusive => file.lock()?,
113            Self::Shared => file.lock_shared()?,
114        }
115        Ok(())
116    }
117}
118
119impl Display for LockedFileMode {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            Self::Shared => write!(f, "shared"),
123            Self::Exclusive => write!(f, "exclusive"),
124        }
125    }
126}
127
128/// A file lock that is automatically released when dropped.
129#[cfg(feature = "tokio")]
130#[derive(Debug)]
131#[must_use]
132pub struct LockedFile(fs_err::File);
133
134#[cfg(feature = "tokio")]
135impl LockedFile {
136    /// Inner implementation for [`LockedFile::acquire`].
137    async fn lock_file(
138        file: fs_err::File,
139        mode: LockedFileMode,
140        resource: &str,
141    ) -> Result<Self, LockedFileError> {
142        trace!(
143            "Checking lock for `{resource}` at `{}`",
144            file.path().user_display()
145        );
146        // If there's no contention, return directly.
147        let try_lock_exclusive = tokio::task::spawn_blocking(move || (mode.try_lock(&file), file));
148        let file = match try_lock_exclusive.await? {
149            (Ok(()), file) => {
150                trace!("Acquired {mode} lock for `{resource}`");
151                return Ok(Self(file));
152            }
153            (Err(err), file) => {
154                // Log error code and enum kind to help debugging more exotic failures.
155                if !is_known_already_locked_error(&err) {
156                    debug!("Try lock {mode} error: {err:?}");
157                }
158                file
159            }
160        };
161
162        // If there's lock contention, wait and break deadlocks with a timeout if necessary.
163        info!(
164            "Waiting to acquire {mode} lock for `{resource}` at `{}`",
165            file.path().user_display(),
166        );
167        let path = file.path().to_path_buf();
168        let lock_exclusive = tokio::task::spawn_blocking(move || (mode.lock(&file), file));
169        let (result, file) = tokio::time::timeout(*LOCK_TIMEOUT, lock_exclusive)
170            .await
171            .map_err(|_| LockedFileError::Timeout {
172                timeout: *LOCK_TIMEOUT,
173                resource: resource.to_string(),
174                path: path.clone(),
175            })??;
176        // Not an fs_err method, we need to build our own path context
177        result.map_err(|err| LockedFileError::Lock {
178            resource: resource.to_string(),
179            path,
180            source: err,
181        })?;
182
183        trace!("Acquired {mode} lock for `{resource}`");
184        Ok(Self(file))
185    }
186
187    /// Inner implementation for [`LockedFile::acquire_no_wait`].
188    fn lock_file_no_wait(file: fs_err::File, mode: LockedFileMode, resource: &str) -> Option<Self> {
189        trace!(
190            "Checking lock for `{resource}` at `{}`",
191            file.path().user_display()
192        );
193        match mode.try_lock(&file) {
194            Ok(()) => {
195                trace!("Acquired {mode} lock for `{resource}`");
196                Some(Self(file))
197            }
198            Err(err) => {
199                // Log error code and enum kind to help debugging more exotic failures.
200                if !is_known_already_locked_error(&err) {
201                    debug!("Try lock error: {err:?}");
202                }
203                debug!("Lock is busy for `{resource}`");
204                None
205            }
206        }
207    }
208
209    /// Acquire a cross-process lock for a resource using a file at the provided path.
210    pub async fn acquire(
211        path: impl AsRef<Path>,
212        mode: LockedFileMode,
213        resource: impl Display,
214    ) -> Result<Self, LockedFileError> {
215        let file = Self::create(&path)?;
216        let resource = resource.to_string();
217        Self::lock_file(file, mode, &resource).await
218    }
219
220    /// Acquire a cross-process lock for a resource using a file at the provided path
221    ///
222    /// Unlike [`LockedFile::acquire`] this function will not wait for the lock to become available.
223    ///
224    /// If the lock is not immediately available, [`None`] is returned.
225    pub fn acquire_no_wait(
226        path: impl AsRef<Path>,
227        mode: LockedFileMode,
228        resource: impl Display,
229    ) -> Option<Self> {
230        let file = Self::create(path).ok()?;
231        let resource = resource.to_string();
232        Self::lock_file_no_wait(file, mode, &resource)
233    }
234
235    #[cfg(unix)]
236    fn create(path: impl AsRef<Path>) -> Result<fs_err::File, LockedFileError> {
237        use rustix::io::Errno;
238        #[expect(clippy::disallowed_types)]
239        use std::{fs::File, os::unix::fs::PermissionsExt};
240        use tempfile::NamedTempFile;
241
242        /// The permissions the lockfile should end up with
243        const DESIRED_MODE: u32 = 0o666;
244
245        #[expect(clippy::disallowed_types)]
246        fn try_set_permissions(file: &File, path: &Path) {
247            if let Err(err) = file.set_permissions(std::fs::Permissions::from_mode(DESIRED_MODE)) {
248                warn!(
249                    "Failed to set permissions on temporary file `{path}`: {err}",
250                    path = path.user_display()
251                );
252            }
253        }
254
255        // If path already exists, return it.
256        if let Ok(file) = fs_err::OpenOptions::new().read(true).open(path.as_ref()) {
257            return Ok(file);
258        }
259
260        // Otherwise, create a temporary file with 666 permissions. We must set
261        // permissions _after_ creating the file, to override the `umask`.
262        let file = if let Some(parent) = path.as_ref().parent() {
263            NamedTempFile::new_in(parent)
264        } else {
265            NamedTempFile::new()
266        }
267        .map_err(LockedFileError::CreateTemporary)?;
268        try_set_permissions(file.as_file(), file.path());
269
270        // Try to move the file to path, but if path exists now, just open path
271        match file.persist_noclobber(path.as_ref()) {
272            Ok(file) => Ok(fs_err::File::from_parts(file, path.as_ref())),
273            Err(err) => {
274                if err.error.kind() == std::io::ErrorKind::AlreadyExists {
275                    fs_err::OpenOptions::new()
276                        .read(true)
277                        .open(path.as_ref())
278                        .map_err(Into::into)
279                } else if matches!(
280                    Errno::from_io_error(&err.error),
281                    Some(Errno::NOTSUP | Errno::INVAL)
282                ) {
283                    // Fallback in case `persist_noclobber`, which uses `renameat2` or
284                    // `renameatx_np` under the hood, is not supported by the FS. Linux reports this
285                    // with `EINVAL` and MacOS with `ENOTSUP`. For these reasons and many others,
286                    // there isn't an ErrorKind we can use here, and in fact on MacOS `ENOTSUP` gets
287                    // mapped to `ErrorKind::Other`
288
289                    // There is a race here where another process has just created the file, and we
290                    // try to open it and get permission errors because the other process hasn't set
291                    // the permission bits yet. This will lead to a transient failure, but unlike
292                    // alternative approaches it won't ever lead to a situation where two processes
293                    // are locking two different files. Also, since `persist_noclobber` is more
294                    // likely to not be supported on special filesystems which don't have permission
295                    // bits, it's less likely to ever matter.
296                    let file = fs_err::OpenOptions::new()
297                        .read(true)
298                        .create(true)
299                        .open(path.as_ref())?;
300
301                    // We don't want to `try_set_permissions` in cases where another user's process
302                    // has already created the lockfile and changed its permissions because we might
303                    // not have permission to change the permissions which would produce a confusing
304                    // warning.
305                    if file
306                        .metadata()
307                        .is_ok_and(|metadata| metadata.permissions().mode() != DESIRED_MODE)
308                    {
309                        try_set_permissions(file.file(), path.as_ref());
310                    }
311                    Ok(file)
312                } else {
313                    let temp_path = err.file.into_temp_path();
314                    Err(LockedFileError::PersistTemporary {
315                        path: <tempfile::TempPath as AsRef<Path>>::as_ref(&temp_path).to_path_buf(),
316                        source: err.error,
317                    })
318                }
319            }
320        }
321    }
322
323    #[cfg(not(unix))]
324    fn create(path: impl AsRef<Path>) -> Result<fs_err::File, LockedFileError> {
325        fs_err::OpenOptions::new()
326            .read(true)
327            .write(true)
328            .create(true)
329            .open(path.as_ref())
330            .map_err(Into::into)
331    }
332}
333
334#[cfg(feature = "tokio")]
335impl Drop for LockedFile {
336    /// Unlock the file.
337    fn drop(&mut self) {
338        if let Err(err) = self.0.unlock() {
339            error!(
340                "Failed to unlock resource at `{}`; program may be stuck: {err}",
341                self.0.path().display()
342            );
343        } else {
344            trace!("Released lock at `{}`", self.0.path().display());
345        }
346    }
347}