Skip to content

Commit c6a415b

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

5 files changed

Lines changed: 75 additions & 18 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: 22 additions & 11 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,16 +407,15 @@ 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 {
399-
Ok(()) => Ok(()),
400-
Err(e) => {
401-
if e.kind() == std::io::ErrorKind::NotFound {
402-
Ok(())
403-
} else {
404-
Err(e)
405-
}
410+
let path = path.as_ref();
411+
remove_dir_all(path).await.or_else(|e| {
412+
if e.kind() == std::io::ErrorKind::NotFound {
413+
Ok(())
414+
} else {
415+
tracing::error!("remove_dir_all_force path: {:?} error: {e:?}", path);
416+
Err(e)
406417
}
407-
}
418+
})
408419
}
409420

410421
/// Create shim files for the package manager.

packages/tools/src/snap-test.ts

Lines changed: 49 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,44 @@ 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(
25+
tasks: (() => Promise<void>)[],
26+
maxConcurrency = cpus().length,
27+
): Promise<void> {
28+
const executing: Promise<void>[] = [];
29+
const errors: Error[] = [];
30+
31+
for (const task of tasks) {
32+
const promise = task()
33+
.catch((error) => {
34+
errors.push(error);
35+
console.error('Task failed:', error);
36+
})
37+
.finally(() => {
38+
executing.splice(executing.indexOf(promise), 1);
39+
});
40+
41+
executing.push(promise);
42+
43+
if (executing.length >= maxConcurrency) {
44+
await Promise.race(executing);
45+
}
46+
}
47+
48+
await Promise.all(executing);
49+
50+
if (errors.length > 0) {
51+
throw new Error(
52+
`${errors.length} test case(s) failed. First error: ${errors[0].message}`,
53+
);
54+
}
55+
}
56+
1957
export async function snapTest() {
2058
const { positionals } = parseArgs({
2159
allowPositionals: true,
@@ -41,16 +79,22 @@ export async function snapTest() {
4179

4280
const casesDir = path.resolve('snap-tests');
4381

44-
const tasks: Promise<void>[] = [];
82+
const taskFunctions: (() => Promise<void>)[] = [];
4583
for (const caseName of fs.readdirSync(casesDir)) {
4684
if (caseName.startsWith('.')) continue; // Skip hidden files like .DS_Store
4785
if (caseName.includes(filter)) {
48-
tasks.push(runTestCase(caseName, tempTmpDir, casesDir));
86+
taskFunctions.push(() => runTestCase(caseName, tempTmpDir, casesDir));
4987
}
5088
}
5189

52-
if (tasks.length > 0) {
53-
await Promise.all(tasks);
90+
if (taskFunctions.length > 0) {
91+
const cpuCount = cpus().length;
92+
console.log(
93+
'Running %d test cases with concurrency limit of %d (CPU count)',
94+
taskFunctions.length,
95+
cpuCount,
96+
);
97+
await runWithConcurrencyLimit(taskFunctions, cpuCount);
5498
}
5599
}
56100

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)