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