diff --git a/crates/pm/src/service/install.rs b/crates/pm/src/service/install.rs index d39541d20..0bee847d4 100644 --- a/crates/pm/src/service/install.rs +++ b/crates/pm/src/service/install.rs @@ -136,13 +136,15 @@ pub async fn install_packages( package.optional == Some(true) || package.dev_optional == Some(true); let task = tokio::spawn(async move { - if !clone_package_once(&name, &version, &resolved, &target_path).await { + if let Err(e) = + clone_package_once(&name, &version, &resolved, &target_path).await + { if is_optional { tracing::warn!("Optional dependency {name} failed (ignored)"); PROGRESS_BAR.inc(1); return Ok(()); } - anyhow::bail!("{name} clone failed"); + return Err(e); } PROGRESS_BAR.inc(1); log_progress(&format!("{name} resolved")); diff --git a/crates/pm/src/service/pipeline/worker.rs b/crates/pm/src/service/pipeline/worker.rs index c636971f3..af9348c8f 100644 --- a/crates/pm/src/service/pipeline/worker.rs +++ b/crates/pm/src/service/pipeline/worker.rs @@ -59,7 +59,9 @@ pub fn start_workers(channels: PipelineChannels, cwd: PathBuf) -> PipelineHandle if let Some(ref parent) = parent_path { wait_clone_if_pending(&parent.to_string_lossy()).await; } - clone_package_once(&name, &version, &tarball_url, &target).await; + if let Err(e) = clone_package_once(&name, &version, &tarball_url, &target).await { + tracing::debug!("Pipeline pre-clone failed for {name}@{version}: {e:#}"); + } }); } }); diff --git a/crates/pm/src/util/cloner.rs b/crates/pm/src/util/cloner.rs index 8a6ebc1ac..2ffbcb4df 100644 --- a/crates/pm/src/util/cloner.rs +++ b/crates/pm/src/util/cloner.rs @@ -13,8 +13,16 @@ use super::retry::create_retry_strategy; use crate::fs; /// Global clone cache shared between pipeline and install phases. -/// Key: target path, Value: () -static CLONE_CACHE: Lazy> = Lazy::new(OnceMap::new); +/// +/// Key: normalized target path. Install (`cwd.join("node_modules/foo")` → +/// forward slashes) and pipeline (`Path::join` injects backslashes on +/// Windows) produce the same logical target with different separators; +/// without normalization OnceMap sees them as distinct keys, dedup fails, +/// and concurrent tasks race on the same destination — manifesting as +/// `ERROR_SHARING_VIOLATION` (os error 32) on Windows. `PathBuf` from +/// `Path::components().collect()` parses both separators uniformly and +/// rebuilds with the OS-preferred one, giving a stable key. +static CLONE_CACHE: Lazy> = Lazy::new(OnceMap::new); /// Number of clones completed. static CLONE_COUNT: AtomicUsize = AtomicUsize::new(0); @@ -24,12 +32,25 @@ pub fn clone_count() -> usize { CLONE_COUNT.load(Ordering::Relaxed) } +/// Normalize a target path into the canonical key used by `CLONE_CACHE`. +#[cfg(windows)] +fn cache_key(target_path: &Path) -> PathBuf { + target_path.components().collect() +} + +#[cfg(not(windows))] +fn cache_key(target_path: &Path) -> PathBuf { + target_path.to_path_buf() +} + /// Wait for a pending clone at the given target path to complete (if any). /// /// Used by the pipeline clone worker to ensure parent packages are /// cloned before their children. pub async fn wait_clone_if_pending(target_path: &str) { - CLONE_CACHE.wait_if_pending(&target_path.to_string()).await; + CLONE_CACHE + .wait_if_pending(&cache_key(Path::new(target_path))) + .await; } /// Clone a package to target path, downloading to cache first if needed. @@ -41,8 +62,9 @@ pub async fn clone_package_once( version: &str, tarball_url: &str, target_path: &Path, -) -> bool { - let key = target_path.to_string_lossy().to_string(); +) -> Result<()> { + let key = cache_key(target_path); + let err_label = format!("{name}@{version}"); let name = name.to_string(); let version = version.to_string(); let tarball_url = tarball_url.to_string(); @@ -59,7 +81,7 @@ pub async fn clone_package_once( .await .inspect_err(|e| { tracing::warn!( - "Clone failed: {}@{} to {}: {}", + "Clone failed: {}@{} to {}: {:#}", name, version, target_path.display(), @@ -73,7 +95,8 @@ pub async fn clone_package_once( Some(()) }) .await - .is_some() + .map(|_| ()) + .ok_or_else(|| anyhow::anyhow!("clone {err_label} failed (see warning log for details)")) } #[cfg(target_os = "macos")] @@ -438,6 +461,20 @@ mod tests { use super::*; + #[cfg(windows)] + #[test] + fn cache_key_normalizes_path_separators() { + // install.rs joins lockfile-derived strings (forward slashes) while + // pipeline workers go through `Path::join` (backslashes). Both must + // produce the same OnceMap key — otherwise concurrent clones race + // and Windows raises ERROR_SHARING_VIOLATION. + let forward = cache_key(Path::new("node_modules/@scope/pkg/node_modules/dep")); + let backward = cache_key(Path::new("node_modules\\@scope\\pkg\\node_modules\\dep")); + let mixed = cache_key(Path::new("node_modules/@scope/pkg\\node_modules\\dep")); + assert_eq!(forward, backward); + assert_eq!(forward, mixed); + } + async fn create_test_file(dir: &Path, name: &str, content: &[u8]) -> Result { let path = dir.join(name); let mut file = fs::File::create(&path).await?; diff --git a/e2e/utoo-pm.ps1 b/e2e/utoo-pm.ps1 index fa9cae30b..c8b77ddc5 100644 --- a/e2e/utoo-pm.ps1 +++ b/e2e/utoo-pm.ps1 @@ -306,6 +306,40 @@ finally { Remove-Item -Recurse -Force $antdxDir -ErrorAction SilentlyContinue } +# Case: pnpm migration (eggjs/egg) +Write-Yellow "Case: pnpm migration (eggjs/egg)" +$eggDir = Join-Path $env:TEMP "utoo-e2e-egg-$(Get-Random)" +try { + git clone --branch next --single-branch --depth 1 https://github.com/eggjs/egg.git $eggDir + Push-Location $eggDir + + utoo install --from pnpm --ignore-scripts --registry=https://registry.npmjs.org + if ($LASTEXITCODE -ne 0) { throw "utoo install --from pnpm failed for eggjs/egg" } + + # Verify workspaces field was added to package.json + node -e "const pkg = require('./package.json'); const ws = pkg.workspaces; if (!ws || !Array.isArray(ws)) throw new Error('workspaces not set'); if (!ws.includes('packages/*')) throw new Error('missing packages/*'); console.log(' workspaces:', ws.length, 'patterns');" + if ($LASTEXITCODE -ne 0) { throw "workspaces field verification failed" } + + # Verify overrides were added + node -e "const pkg = require('./package.json'); if (!pkg.overrides) throw new Error('overrides not set'); if (!pkg.overrides.vite) throw new Error('vite override missing'); console.log(' overrides:', Object.keys(pkg.overrides).length, 'entries');" + if ($LASTEXITCODE -ne 0) { throw "overrides field verification failed" } + + # Verify .utoo.toml was created with catalogs + if (-not (Test-Path ".utoo.toml")) { throw ".utoo.toml not created" } + $tomlContent = Get-Content .utoo.toml -Raw + if ($tomlContent -notmatch 'lodash') { throw "catalog missing lodash" } + if ($tomlContent -notmatch 'path-to-regexp') { throw "named catalog missing" } + + # Verify node_modules was created (install ran successfully) + if (-not (Test-Path "node_modules")) { throw "node_modules not created" } + + Write-Green "PASS: pnpm migration (eggjs/egg)" +} +finally { + Pop-Location + Remove-Item -Recurse -Force $eggDir -ErrorAction SilentlyContinue +} + # Case: install-node + esbuild postinstall Write-Yellow "Case: install-node + esbuild" $esbuildDir = Join-Path $env:TEMP "utoo-e2e-esbuild-$(Get-Random)"