Skip to main content

next_napi_bindings/
lockfile.rs

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