next_swc_napi/
lockfile.rs

1use std::{
2    fs::{File, OpenOptions},
3    mem::ManuallyDrop,
4    sync::Mutex,
5};
6
7use anyhow::Context;
8use napi::bindgen_prelude::External;
9
10/// A wrapper around [`File`] that is passed to JS, and is set to `None` when [`lockfile_unlock`] is
11/// called.
12///
13/// This uses [`ManuallyDrop`] to prevent exposing close-on-drop semantics to JS, as its not
14/// idiomatic to rely on GC behaviors in JS.
15///
16/// When the file is unlocked, the file at that path will be deleted (best-effort).
17type JsLockfile = Mutex<ManuallyDrop<Option<LockfileInner>>>;
18
19pub struct LockfileInner {
20    file: File,
21    #[cfg(not(windows))]
22    path: std::path::PathBuf,
23}
24
25#[napi(ts_return_type = "{ __napiType: \"Lockfile\" } | null")]
26pub fn lockfile_try_acquire_sync(path: String) -> napi::Result<Option<External<JsLockfile>>> {
27    let mut open_options = OpenOptions::new();
28    open_options.write(true).create(true);
29
30    // On Windows, we don't use `File::lock` because that grabs a mandatory lock. That can break
31    // tools or code that read the contents of the `.next` directory because the mandatory lock
32    // file will fail with EBUSY when read. Instead, we open a file with write mode, but without
33    // `FILE_SHARE_WRITE`. That gives us behavior closer to what we get on POSIX platforms.
34    //
35    // On POSIX platforms, Rust uses `flock` which creates an advisory lock, which can be
36    // read/written/deleted.
37
38    #[cfg(windows)]
39    return {
40        use std::os::windows::fs::OpenOptionsExt;
41
42        use windows_sys::Win32::{Foundation, Storage::FileSystem};
43
44        open_options
45            .share_mode(FileSystem::FILE_SHARE_READ | FileSystem::FILE_SHARE_DELETE)
46            .custom_flags(FileSystem::FILE_FLAG_DELETE_ON_CLOSE);
47        match open_options.open(&path) {
48            Ok(file) => Ok(Some(External::new(Mutex::new(ManuallyDrop::new(Some(
49                LockfileInner { file },
50            )))))),
51            Err(err)
52                if err.raw_os_error()
53                    == Some(Foundation::ERROR_SHARING_VIOLATION.try_into().unwrap()) =>
54            {
55                Ok(None)
56            }
57            Err(err) => Err(err.into()),
58        }
59    };
60
61    #[cfg(not(windows))]
62    return {
63        use std::fs::TryLockError;
64
65        let file = open_options.open(&path)?;
66        match file.try_lock() {
67            Ok(_) => Ok(Some(External::new(Mutex::new(ManuallyDrop::new(Some(
68                LockfileInner {
69                    file,
70                    path: path.into(),
71                },
72            )))))),
73            Err(TryLockError::WouldBlock) => Ok(None),
74            Err(TryLockError::Error(err)) => Err(err.into()),
75        }
76    };
77}
78
79#[napi(ts_return_type = "Promise<{ __napiType: \"Lockfile\" } | null>")]
80pub async fn lockfile_try_acquire(path: String) -> napi::Result<Option<External<JsLockfile>>> {
81    tokio::task::spawn_blocking(move || lockfile_try_acquire_sync(path))
82        .await
83        .context("panicked while attempting to acquire lockfile")?
84}
85
86#[napi]
87pub fn lockfile_unlock_sync(
88    #[napi(ts_arg_type = "{ __napiType: \"Lockfile\" }")] lockfile: External<JsLockfile>,
89) {
90    // We don't need the file handle anymore, so we don't need to call `File::unlock`. Locks are
91    // released during `drop`. Remove it from the `ManuallyDrop` wrapper.
92    let Some(inner): Option<LockfileInner> = lockfile
93        .lock()
94        .expect("poisoned: another thread panicked during `lockfile_unlock_sync`?")
95        .take()
96    else {
97        return;
98    };
99
100    // - We use `FILE_FLAG_DELETE_ON_CLOSE` on Windows, so we don't need to delete the file there.
101    // - Ignore possible errors while removing the file, it only matters that we release the lock.
102    // - Delete *before* releasing the lock to avoid race conditions where we might accidentally
103    //   delete another process's lockfile. This relies on POSIX semantics, letting us delete an
104    //   open file.
105    #[cfg(not(windows))]
106    let _ = std::fs::remove_file(inner.path);
107
108    drop(inner.file);
109}
110
111#[napi]
112pub async fn lockfile_unlock(
113    #[napi(ts_arg_type = "{ __napiType: \"Lockfile\" }")] lockfile: External<JsLockfile>,
114) -> napi::Result<()> {
115    Ok(
116        tokio::task::spawn_blocking(move || lockfile_unlock_sync(lockfile))
117            .await
118            .context("panicked while attempting to unlock lockfile")?,
119    )
120}