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