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_secs(300);
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                debug!("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        debug!("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                debug!("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        #[allow(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        #[allow(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()
257            .read(true)
258            .write(true)
259            .open(path.as_ref())
260        {
261            return Ok(file);
262        }
263
264        // Otherwise, create a temporary file with 666 permissions. We must set
265        // permissions _after_ creating the file, to override the `umask`.
266        let file = if let Some(parent) = path.as_ref().parent() {
267            NamedTempFile::new_in(parent)
268        } else {
269            NamedTempFile::new()
270        }
271        .map_err(LockedFileError::CreateTemporary)?;
272        try_set_permissions(file.as_file(), file.path());
273
274        // Try to move the file to path, but if path exists now, just open path
275        match file.persist_noclobber(path.as_ref()) {
276            Ok(file) => Ok(fs_err::File::from_parts(file, path.as_ref())),
277            Err(err) => {
278                if err.error.kind() == std::io::ErrorKind::AlreadyExists {
279                    fs_err::OpenOptions::new()
280                        .read(true)
281                        .write(true)
282                        .open(path.as_ref())
283                        .map_err(Into::into)
284                } else if matches!(
285                    Errno::from_io_error(&err.error),
286                    Some(Errno::NOTSUP | Errno::INVAL)
287                ) {
288                    // Fallback in case `persist_noclobber`, which uses `renameat2` or
289                    // `renameatx_np` under the hood, is not supported by the FS. Linux reports this
290                    // with `EINVAL` and MacOS with `ENOTSUP`. For these reasons and many others,
291                    // there isn't an ErrorKind we can use here, and in fact on MacOS `ENOTSUP` gets
292                    // mapped to `ErrorKind::Other`
293
294                    // There is a race here where another process has just created the file, and we
295                    // try to open it and get permission errors because the other process hasn't set
296                    // the permission bits yet. This will lead to a transient failure, but unlike
297                    // alternative approaches it won't ever lead to a situation where two processes
298                    // are locking two different files. Also, since `persist_noclobber` is more
299                    // likely to not be supported on special filesystems which don't have permission
300                    // bits, it's less likely to ever matter.
301                    let file = fs_err::OpenOptions::new()
302                        .read(true)
303                        .write(true)
304                        .create(true)
305                        .open(path.as_ref())?;
306
307                    // We don't want to `try_set_permissions` in cases where another user's process
308                    // has already created the lockfile and changed its permissions because we might
309                    // not have permission to change the permissions which would produce a confusing
310                    // warning.
311                    if file
312                        .metadata()
313                        .is_ok_and(|metadata| metadata.permissions().mode() != DESIRED_MODE)
314                    {
315                        try_set_permissions(file.file(), path.as_ref());
316                    }
317                    Ok(file)
318                } else {
319                    let temp_path = err.file.into_temp_path();
320                    Err(LockedFileError::PersistTemporary {
321                        path: <tempfile::TempPath as AsRef<Path>>::as_ref(&temp_path).to_path_buf(),
322                        source: err.error,
323                    })
324                }
325            }
326        }
327    }
328
329    #[cfg(not(unix))]
330    fn create(path: impl AsRef<Path>) -> Result<fs_err::File, LockedFileError> {
331        fs_err::OpenOptions::new()
332            .read(true)
333            .write(true)
334            .create(true)
335            .open(path.as_ref())
336            .map_err(Into::into)
337    }
338}
339
340#[cfg(feature = "tokio")]
341impl Drop for LockedFile {
342    /// Unlock the file.
343    fn drop(&mut self) {
344        if let Err(err) = self.0.unlock() {
345            error!(
346                "Failed to unlock resource at `{}`; program may be stuck: {err}",
347                self.0.path().display()
348            );
349        } else {
350            debug!("Released lock at `{}`", self.0.path().display());
351        }
352    }
353}