Skip to content

Commit 57e4322

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

5 files changed

Lines changed: 60 additions & 10 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.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ members = ["bench", "crates/*", "packages/cli/binding"]
66
authors = ["Vite+ Authors"]
77
edition = "2024"
88
license = "BUSL-1.1"
9-
rust-version = "1.88.0"
9+
rust-version = "1.89.0"
1010

1111
[workspace.lints.rust]
1212
absolute_paths_not_starting_with_crate = "warn"

crates/vite_install/src/package_manager.rs

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -374,9 +374,21 @@ async fn download_package_manager(
374374
tracing::debug!("Rename package dir to {}", bin_name);
375375
tokio::fs::rename(&target_dir_tmp.join("package"), &target_dir_tmp.join(&bin_name)).await?;
376376

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

@@ -395,12 +407,13 @@ async fn download_package_manager(
395407
/// Remove the directory and all its contents.
396408
/// Ignore the error if the directory is not found.
397409
async fn remove_dir_all_force(path: impl AsRef<Path>) -> Result<(), std::io::Error> {
398-
match remove_dir_all(path).await {
410+
match remove_dir_all(path.as_ref()).await {
399411
Ok(()) => Ok(()),
400412
Err(e) => {
401413
if e.kind() == std::io::ErrorKind::NotFound {
402414
Ok(())
403415
} else {
416+
tracing::error!("remove_dir_all_force path: {:?} error: {e:?}", path.as_ref());
404417
Err(e)
405418
}
406419
}

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)