Skip to content

Commit c5cd576

Browse files
committed
fix: lock to ensure atomicity of remove + rename operations
1 parent 4bb4def commit c5cd576

6 files changed

Lines changed: 93 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ jobs:
172172

173173
- name: Run CLI E2E tests
174174
run: |
175-
pnpm test
175+
RUST_BACKTRACE=1 pnpm test
176176
git diff --exit-code
177177
178178
install-e2e-test:

Cargo.lock

Lines changed: 14 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ criterion = { version = "0.7", features = ["html_reports"] }
4141
crossterm = { version = "0.29.0", features = ["event-stream"] }
4242
directories = "6.0.0"
4343
flate2 = "1.0.35"
44+
fs4 = "0.13"
4445
futures-util = "0.3.31"
4546
hex = "0.4.3"
4647
httpmock = "0.7"

crates/vite_install/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ rust-version.workspace = true
1111
backon = { workspace = true }
1212
directories = { workspace = true }
1313
flate2 = { workspace = true }
14+
fs4 = { workspace = true }
1415
futures-util = { workspace = true }
1516
hex = { workspace = true }
1617
indoc = { workspace = true }

crates/vite_install/src/package_manager.rs

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ use std::{
77
process::{ExitStatus, Stdio},
88
};
99

10+
use fs4::fs_std::FileExt;
11+
#[cfg(not(target_os = "windows"))]
12+
use nix::sys::statfs::statfs;
1013
use semver::{Version, VersionReq};
1114
use serde::{Deserialize, Serialize};
1215
use tokio::{fs::remove_dir_all, process::Command};
@@ -320,6 +323,65 @@ async fn get_latest_version(package_manager_type: PackageManagerType) -> Result<
320323
Ok(package_json.version)
321324
}
322325

326+
/// Check if a path is on an NFS filesystem.
327+
/// NFS file locking can be unreliable, so we skip locking on NFS.
328+
#[cfg(not(target_os = "windows"))]
329+
fn is_on_nfs(path: &Path) -> bool {
330+
match statfs(path) {
331+
Ok(stat) => {
332+
// NFS magic numbers:
333+
// - Linux: 0x6969 (NFS_SUPER_MAGIC)
334+
// - macOS/BSD: Different approach - check f_fstypename
335+
#[cfg(target_os = "linux")]
336+
{
337+
stat.filesystem_type().0 == nix::sys::statfs::NFS_SUPER_MAGIC.0
338+
}
339+
#[cfg(not(target_os = "linux"))]
340+
{
341+
// On macOS and BSD, check the filesystem type name
342+
let fs_type = stat.filesystem_type_name();
343+
fs_type == "nfs"
344+
}
345+
}
346+
Err(_) => {
347+
// If we can't determine, assume it's not NFS and try to lock
348+
false
349+
}
350+
}
351+
}
352+
353+
#[cfg(target_os = "windows")]
354+
fn is_on_nfs(_path: &Path) -> bool {
355+
// On Windows, we could check for network drives, but for simplicity
356+
// we'll assume local filesystem. Windows file locking is generally reliable.
357+
false
358+
}
359+
360+
/// A file lock guard that holds the lock until dropped.
361+
/// If the filesystem is NFS, no actual lock is acquired.
362+
struct FileLockGuard {
363+
_file: Option<File>,
364+
}
365+
366+
/// Acquire a file lock at the specified path.
367+
/// Automatically detects NFS filesystems and skips locking if on NFS.
368+
/// Returns a guard that releases the lock when dropped.
369+
fn acquire_file_lock(lock_path: &Path) -> Result<FileLockGuard, Error> {
370+
// Check if the path is on NFS
371+
if is_on_nfs(lock_path) {
372+
tracing::debug!("Path {:?} is on NFS filesystem, skipping file lock", lock_path);
373+
return Ok(FileLockGuard { _file: None });
374+
}
375+
376+
tracing::debug!("Acquiring lock file: {:?}", lock_path);
377+
let lock_file = File::create(lock_path)?;
378+
// Acquire exclusive lock (blocks until available)
379+
lock_file.lock_exclusive()?;
380+
tracing::debug!("Lock acquired: {:?}", lock_path);
381+
382+
Ok(FileLockGuard { _file: Some(lock_file) })
383+
}
384+
323385
/// Download the package manager and extract it to the cache directory.
324386
/// Return the install directory, e.g. $`CACHE_DIR/vite/package_manager/pnpm/10.0.0/pnpm`
325387
async fn download_package_manager(
@@ -374,9 +436,17 @@ async fn download_package_manager(
374436
tracing::debug!("Rename package dir to {}", bin_name);
375437
tokio::fs::rename(&target_dir_tmp.join("package"), &target_dir_tmp.join(&bin_name)).await?;
376438

377-
// check bin_file again, for the concurrent download cases
439+
// Use a file-based lock to ensure atomicity of remove + rename operations
440+
// This prevents DirectoryNotEmpty exceptions when multiple processes/threads
441+
// try to install the same package manager version concurrently.
442+
// The lock is automatically skipped on NFS filesystems where locking is unreliable.
443+
let lock_path = parent_dir.join(format!("{version}.lock"));
444+
let _lock_guard = acquire_file_lock(lock_path.as_ref())?;
445+
446+
// Check again after acquiring the lock, in case another thread completed
447+
// the installation while we were downloading
378448
if is_exists_file(&bin_file)? {
379-
tracing::debug!("bin_file already exists, skip rename");
449+
tracing::debug!("bin_file already exists after lock acquisition, skip rename");
380450
return Ok(install_dir);
381451
}
382452

@@ -395,12 +465,13 @@ async fn download_package_manager(
395465
/// Remove the directory and all its contents.
396466
/// Ignore the error if the directory is not found.
397467
async fn remove_dir_all_force(path: impl AsRef<Path>) -> Result<(), std::io::Error> {
398-
match remove_dir_all(path).await {
468+
match remove_dir_all(path.as_ref()).await {
399469
Ok(()) => Ok(()),
400470
Err(e) => {
401471
if e.kind() == std::io::ErrorKind::NotFound {
402472
Ok(())
403473
} else {
474+
tracing::error!("remove_dir_all_force path: {:?} error: {e:?}", path.as_ref());
404475
Err(e)
405476
}
406477
}

packages/tools/src/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ const DEFAULT_PASSTHROUGH_ENVS = [
126126
'*_TOKEN',
127127
// oxc specific
128128
'OXLINT_*',
129+
// Rust specific
130+
'RUST_*',
129131
].map(env => new Minimatch(env));
130132

131133
export function isPassThroughEnv(env: string) {

0 commit comments

Comments
 (0)