Skip to content

Commit 1d5153a

Browse files
committed
fix: lock to ensure atomicity of remove + rename operations
1 parent a030058 commit 1d5153a

7 files changed

Lines changed: 76 additions & 12 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: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::{
77
process::{ExitStatus, Stdio},
88
};
99

10+
use fs4::fs_std::FileExt;
1011
use semver::{Version, VersionReq};
1112
use serde::{Deserialize, Serialize};
1213
use tokio::{fs::remove_dir_all, process::Command};
@@ -374,9 +375,21 @@ async fn download_package_manager(
374375
tracing::debug!("Rename package dir to {}", bin_name);
375376
tokio::fs::rename(&target_dir_tmp.join("package"), &target_dir_tmp.join(&bin_name)).await?;
376377

377-
// check bin_file again, for the concurrent download cases
378+
// Use a file-based lock to ensure atomicity of remove + rename operations
379+
// This prevents DirectoryNotEmpty exceptions when multiple processes/threads
380+
// try to install the same package manager version concurrently.
381+
// The lock is automatically skipped on NFS filesystems where locking is unreliable.
382+
let lock_path = parent_dir.join(format!("{version}.lock"));
383+
tracing::debug!("Acquire lock file: {:?}", lock_path);
384+
let lock_file = File::create(lock_path.as_path())?;
385+
// Acquire exclusive lock (blocks until available)
386+
lock_file.lock_exclusive()?;
387+
tracing::debug!("Lock acquired: {:?}", lock_path);
388+
389+
// Check again after acquiring the lock, in case another thread completed
390+
// the installation while we were downloading
378391
if is_exists_file(&bin_file)? {
379-
tracing::debug!("bin_file already exists, skip rename");
392+
tracing::debug!("bin_file already exists after lock acquisition, skip rename");
380393
return Ok(install_dir);
381394
}
382395

@@ -395,12 +408,13 @@ async fn download_package_manager(
395408
/// Remove the directory and all its contents.
396409
/// Ignore the error if the directory is not found.
397410
async fn remove_dir_all_force(path: impl AsRef<Path>) -> Result<(), std::io::Error> {
398-
match remove_dir_all(path).await {
411+
match remove_dir_all(path.as_ref()).await {
399412
Ok(()) => Ok(()),
400413
Err(e) => {
401414
if e.kind() == std::io::ErrorKind::NotFound {
402415
Ok(())
403416
} else {
417+
tracing::error!("remove_dir_all_force path: {:?} error: {e:?}", path.as_ref());
404418
Err(e)
405419
}
406420
}

packages/tools/src/snap-test.ts

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import cp from 'node:child_process';
22
import { randomUUID } from 'node:crypto';
33
import fs from 'node:fs';
44
import fsPromises from 'node:fs/promises';
5-
import { tmpdir } from 'node:os';
5+
import { cpus, tmpdir } from 'node:os';
66
import path from 'node:path';
77
import { debuglog, parseArgs, promisify } from 'node:util';
88

@@ -16,6 +16,35 @@ const exec = async (command: string, options: cp.ExecOptionsWithStringEncoding)
1616
process.platform === 'win32' ? { ...options, shell: 'pwsh.exe' } : options,
1717
);
1818

19+
/**
20+
* Run tasks with limited concurrency based on CPU count.
21+
* @param tasks Array of task functions to execute
22+
* @param maxConcurrency Maximum number of concurrent tasks (defaults to CPU count)
23+
*/
24+
async function runWithConcurrencyLimit<T>(
25+
tasks: (() => Promise<T>)[],
26+
maxConcurrency = cpus().length,
27+
): Promise<T[]> {
28+
const results: T[] = [];
29+
const executing: Promise<void>[] = [];
30+
31+
for (const task of tasks) {
32+
const promise = task().then((result) => {
33+
results.push(result);
34+
executing.splice(executing.indexOf(promise), 1);
35+
});
36+
37+
executing.push(promise);
38+
39+
if (executing.length >= maxConcurrency) {
40+
await Promise.race(executing);
41+
}
42+
}
43+
44+
await Promise.all(executing);
45+
return results;
46+
}
47+
1948
export async function snapTest() {
2049
const { positionals } = parseArgs({
2150
allowPositionals: true,
@@ -41,16 +70,22 @@ export async function snapTest() {
4170

4271
const casesDir = path.resolve('snap-tests');
4372

44-
const tasks: Promise<void>[] = [];
73+
const taskFunctions: (() => Promise<void>)[] = [];
4574
for (const caseName of fs.readdirSync(casesDir)) {
4675
if (caseName.startsWith('.')) continue; // Skip hidden files like .DS_Store
4776
if (caseName.includes(filter)) {
48-
tasks.push(runTestCase(caseName, tempTmpDir, casesDir));
77+
taskFunctions.push(() => runTestCase(caseName, tempTmpDir, casesDir));
4978
}
5079
}
5180

52-
if (tasks.length > 0) {
53-
await Promise.all(tasks);
81+
if (taskFunctions.length > 0) {
82+
const cpuCount = cpus().length;
83+
console.log(
84+
'Running %d test cases with concurrency limit of %d (CPU count)',
85+
taskFunctions.length,
86+
cpuCount,
87+
);
88+
await runWithConcurrencyLimit(taskFunctions, cpuCount);
5489
}
5590
}
5691

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)