Skip to content

Commit 3cc727b

Browse files
authored
feat(install): add file locking to prevent concurrent installation issues (#289)
Add file locking to prevent race conditions in package manager installation Added file-based locking to ensure atomic operations during package manager downloads, preventing concurrent processes from conflicting when installing the same package manager version. close #140 ## Changes - Added file-based locking using `fs4` crate to synchronize critical operations - Added NFS filesystem detection to skip locking on NFS where it may be unreliable - Improved error logging in `remove_dir_all_force` to provide better debugging information - Added `RUST_BACKTRACE=1` to CLI E2E tests for better error diagnostics - Added `RUST_*` to passthrough environment variables in tools utils ## Why This change prevents race conditions when multiple processes attempt to install the same package manager version simultaneously. The file lock ensures that the remove and rename operations are performed atomically, avoiding conflicts between concurrent installation attempts.
1 parent a030058 commit 3cc727b

7 files changed

Lines changed: 87 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/__tests__/__snapshots__/utils.spec.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ exports[`replaceUnstableOutput() > replace ignore pnpm request warning log 1`] =
2626
Packages:"
2727
`;
2828
29+
exports[`replaceUnstableOutput() > replace pnpm registry request error warning log 1`] = `"Progress: resolved"`;
30+
2931
exports[`replaceUnstableOutput() > replace tsdown output 1`] = `
3032
"ℹ tsdown v<semver> powered by rolldown v<semver>
3133
ℹ entry: src/index.ts

packages/tools/src/__tests__/utils.spec.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,14 @@ https://registry.yarnpkg.com/testnpm2/-/testnpm2-1.0.0.tgz
144144
`;
145145
expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();
146146
});
147+
148+
test('replace pnpm registry request error warning log', () => {
149+
const output = `
150+
 WARN  GET https://registry.npmjs.org/test-vite-plus-install error (ECONNRESET). Will retry in 10 seconds. 2 retries left.
151+
Progress: resolved
152+
`;
153+
expect(replaceUnstableOutput(output.trim())).toMatchSnapshot();
154+
});
147155
});
148156

149157
describe('isPassThroughEnv()', () => {

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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export function replaceUnstableOutput(output: string, cwd?: string) {
3535
.replaceAll(/?WARN\s+Request\s+took .+?\n/g, '')
3636
.replaceAll(/Scope: all \d+ workspace projects/g, 'Scope: all <variable> workspace projects')
3737
.replaceAll(/\++\n/g, '+<repeat>\n')
38+
// ignore pnpm registry request error warning log
39+
.replaceAll(/?WARN\s+GET\s+https:\/\/registry\..+?\n/g, '')
3840
// ignore yarn YN0013, because it's unstable output, only exists on CI environment
3941
// ➤ YN0013: │ A package was added to the project (+ 0.7 KiB).
4042
.replaceAll(/ YN0013:[^\n]+\n/g, '')
@@ -126,6 +128,8 @@ const DEFAULT_PASSTHROUGH_ENVS = [
126128
'*_TOKEN',
127129
// oxc specific
128130
'OXLINT_*',
131+
// Rust specific
132+
'RUST_*',
129133
].map(env => new Minimatch(env));
130134

131135
export function isPassThroughEnv(env: string) {

0 commit comments

Comments
 (0)