Skip to main content

uv_install_wheel/
linker.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::io;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5use std::time::SystemTime;
6
7use fs_err as fs;
8use fs_err::DirEntry;
9use itertools::Itertools;
10use reflink_copy as reflink;
11use rustc_hash::FxHashMap;
12use serde::{Deserialize, Serialize};
13use tempfile::tempdir_in;
14use tracing::{debug, instrument, trace, warn};
15use walkdir::WalkDir;
16
17use uv_distribution_filename::WheelFilename;
18use uv_fs::Simplified;
19use uv_preview::{Preview, PreviewFeature};
20use uv_warnings::{warn_user, warn_user_once};
21
22use crate::Error;
23
24/// Avoid and track conflicts between packages.
25#[expect(clippy::struct_field_names)]
26#[derive(Debug, Default)]
27pub struct Locks {
28    /// The parent directory of a file in a synchronized copy
29    copy_dir_locks: Mutex<FxHashMap<PathBuf, Arc<Mutex<()>>>>,
30    /// Top level files and directories in site-packages, stored as relative path, and wheels they
31    /// are from, with the absolute paths in the unpacked wheel.
32    site_packages_paths: Mutex<FxHashMap<PathBuf, BTreeSet<(WheelFilename, PathBuf)>>>,
33    /// Preview settings for feature flags.
34    preview: Preview,
35}
36
37impl Locks {
38    /// Create a new Locks instance with the given preview settings.
39    pub fn new(preview: Preview) -> Self {
40        Self {
41            copy_dir_locks: Mutex::new(FxHashMap::default()),
42            site_packages_paths: Mutex::new(FxHashMap::default()),
43            preview,
44        }
45    }
46
47    /// Register which package installs which (top level) path.
48    ///
49    /// This is later used warn when different files at the same path exist in multiple packages.
50    ///
51    /// The first non-self argument is the target path relative to site-packages, the second is the
52    /// source path in the unpacked wheel.
53    fn register_installed_path(&self, relative: &Path, absolute: &Path, wheel: &WheelFilename) {
54        debug_assert!(!relative.is_absolute());
55        debug_assert!(absolute.is_absolute());
56
57        // Only register top level entries, these are the only ones we have reliably as cloning
58        // a directory on macOS traverses outside our code.
59        if relative.components().count() != 1 {
60            return;
61        }
62
63        self.site_packages_paths
64            .lock()
65            .unwrap()
66            .entry(relative.to_path_buf())
67            .or_default()
68            .insert((wheel.clone(), absolute.to_path_buf()));
69    }
70
71    /// Warn when the same file with different contents exists in multiple packages.
72    ///
73    /// The intent is to detect different variants of the same package installed over each other,
74    /// or different packages using the same top-level module name, which cause non-deterministic
75    /// failures only surfacing at runtime. See <https://github.com/astral-sh/uv/pull/13437> for a
76    /// list of cases.
77    ///
78    /// The check has some false negatives. It is rather too lenient than too strict, and includes
79    /// support for namespace packages that include the same `__init__.py` file, e.g., gpu-a and
80    /// gpu-b both including the same `gpu/__init__.py`.
81    ///
82    /// We assume that all wheels of a package have the same module(s), so a conflict between
83    /// installing two unpacked wheels is a conflict between two packages.
84    ///
85    /// # Performance
86    ///
87    /// When there are no namespace packages, this method is a Mutex lock and a hash map iteration.
88    ///
89    /// When there are namespace packages, we only traverse into directories shared by at least two
90    /// packages. For example, for namespace packages gpu-a, gpu-b, and gpu-c with
91    /// `gpu/a/__init__.py`, `gpu/b/__init__.py`, and `gpu/c/__init__.py` respectively, we only
92    /// need to read the `gpu` directory. If there is a deeper shared directory, we only recurse
93    /// down to this directory. As packages without conflicts generally do not share many
94    /// directories, we do not recurse far.
95    ///
96    /// For each directory, we analyze all packages sharing the directory at the same time, reading
97    /// the directory in each unpacked wheel only once. Effectively, we perform a parallel directory
98    /// walk with early exit.
99    ///
100    /// We avoid reading the actual file contents and assume they are the same when their file
101    /// length matches. This also excludes the same empty `__init__.py` files being reported as
102    /// conflicting.
103    pub fn warn_package_conflicts(self) -> Result<(), io::Error> {
104        // This warning is currently in preview.
105        if !self
106            .preview
107            .is_enabled(PreviewFeature::DetectModuleConflicts)
108        {
109            return Ok(());
110        }
111
112        for (relative, wheels) in &*self.site_packages_paths.lock().unwrap() {
113            // Fast path: Only one package is using this module name, no conflicts.
114            let mut wheel_iter = wheels.iter();
115            let Some(first_wheel) = wheel_iter.next() else {
116                debug_assert!(false, "at least one wheel");
117                continue;
118            };
119            if wheel_iter.next().is_none() {
120                continue;
121            }
122
123            // TODO(konsti): This assumes a path is either a file or a directory in all wheels.
124            let file_type = fs_err::metadata(&first_wheel.1)?.file_type();
125            if file_type.is_file() {
126                // Handle conflicts between files directly in site-packages without a module
127                // directory enclosing them.
128                let files: BTreeSet<(&WheelFilename, u64)> = wheels
129                    .iter()
130                    .map(|(wheel, absolute)| Ok((wheel, absolute.metadata()?.len())))
131                    .collect::<Result<_, io::Error>>()?;
132                Self::warn_file_conflict(relative, &files);
133            } else if file_type.is_dir() {
134                // Don't early return if the method returns true, so we show warnings for each
135                // top-level module.
136                Self::warn_directory_conflict(relative, wheels)?;
137            } else {
138                // We don't expect any other file type, but it's ok if this check has false
139                // negatives.
140            }
141        }
142
143        Ok(())
144    }
145
146    /// Analyze a directory for conflicts.
147    ///
148    /// If there are any non-identical files (checked by size) included in more than one wheel,
149    /// report this file and return.
150    ///
151    /// If there are any directories included in more than one wheel, recurse to analyze whether
152    /// the directories contain conflicting files.
153    ///
154    /// Returns `true` if a warning was emitted.
155    fn warn_directory_conflict(
156        directory: &Path,
157        wheels: &BTreeSet<(WheelFilename, PathBuf)>,
158    ) -> Result<bool, io::Error> {
159        // The files in the directory, as paths relative to the site-packages, with their origin and
160        // size.
161        let mut files: BTreeMap<PathBuf, BTreeSet<(&WheelFilename, u64)>> = BTreeMap::default();
162        // The directories in the directory, as paths relative to the site-packages, with their
163        // origin and absolute path.
164        let mut subdirectories: BTreeMap<PathBuf, BTreeSet<(WheelFilename, PathBuf)>> =
165            BTreeMap::default();
166
167        // Read the shared directory in each unpacked wheel.
168        for (wheel, absolute) in wheels {
169            for dir_entry in fs_err::read_dir(absolute)? {
170                let dir_entry = dir_entry?;
171                let relative = directory.join(dir_entry.file_name());
172                let file_type = dir_entry.file_type()?;
173                if file_type.is_file() {
174                    files
175                        .entry(relative)
176                        .or_default()
177                        .insert((wheel, dir_entry.metadata()?.len()));
178                } else if file_type.is_dir() {
179                    subdirectories
180                        .entry(relative)
181                        .or_default()
182                        .insert((wheel.clone(), dir_entry.path()));
183                } else {
184                    // We don't expect any other file type, but it's ok if this check has false
185                    // negatives.
186                }
187            }
188        }
189
190        for (file, file_wheels) in files {
191            if Self::warn_file_conflict(&file, &file_wheels) {
192                return Ok(true);
193            }
194        }
195
196        for (subdirectory, subdirectory_wheels) in subdirectories {
197            if subdirectory_wheels.len() == 1 {
198                continue;
199            }
200            // If there are directories shared between multiple wheels, recurse to check them
201            // for shared files.
202            if Self::warn_directory_conflict(&subdirectory, &subdirectory_wheels)? {
203                return Ok(true);
204            }
205        }
206
207        Ok(false)
208    }
209
210    /// Check if all files are the same size, if so assume they are identical.
211    ///
212    /// It's unlikely that two modules overlap with different contents but their files all have
213    /// the same length, so we use this heuristic in this performance critical path to avoid
214    /// reading potentially large files.
215    fn warn_file_conflict(file: &Path, file_wheels: &BTreeSet<(&WheelFilename, u64)>) -> bool {
216        let Some((_, file_len)) = file_wheels.first() else {
217            debug_assert!(false, "Always at least one element");
218            return false;
219        };
220        if !file_wheels
221            .iter()
222            .any(|(_, file_len_other)| file_len_other != file_len)
223        {
224            return false;
225        }
226
227        let packages = file_wheels
228            .iter()
229            .map(|(wheel_filename, _file_len)| {
230                format!("* {} ({})", wheel_filename.name, wheel_filename)
231            })
232            .join("\n");
233        warn_user!(
234            "The file `{}` is provided by more than one package, \
235            which causes an install race condition and can result in a broken module. \
236            Packages containing the file:\n{}",
237            file.user_display(),
238            packages
239        );
240
241        // Assumption: There is generally two packages that have a conflict. The output is
242        // more helpful with a single message that calls out the packages
243        // rather than being comprehensive about the conflicting files.
244        true
245    }
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(deny_unknown_fields, rename_all = "kebab-case")]
250#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
251#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
252pub enum LinkMode {
253    /// Clone (i.e., copy-on-write or reflink) packages from the wheel into the `site-packages` directory.
254    #[serde(alias = "reflink")]
255    #[cfg_attr(feature = "clap", value(alias = "reflink"))]
256    Clone,
257    /// Copy packages from the wheel into the `site-packages` directory.
258    Copy,
259    /// Hard link packages from the wheel into the `site-packages` directory.
260    Hardlink,
261    /// Symbolically link packages from the wheel into the `site-packages` directory.
262    Symlink,
263}
264
265impl Default for LinkMode {
266    fn default() -> Self {
267        if cfg!(any(target_os = "macos", target_os = "ios")) {
268            Self::Clone
269        } else {
270            Self::Hardlink
271        }
272    }
273}
274
275impl LinkMode {
276    /// Extract a wheel by linking all of its files into site packages.
277    #[instrument(skip_all)]
278    pub fn link_wheel_files(
279        self,
280        site_packages: impl AsRef<Path>,
281        wheel: impl AsRef<Path>,
282        locks: &Locks,
283        filename: &WheelFilename,
284    ) -> Result<usize, Error> {
285        match self {
286            Self::Clone => clone_wheel_files(site_packages, wheel, locks, filename),
287            Self::Copy => copy_wheel_files(site_packages, wheel, locks, filename),
288            Self::Hardlink => hardlink_wheel_files(site_packages, wheel, locks, filename),
289            Self::Symlink => symlink_wheel_files(site_packages, wheel, locks, filename),
290        }
291    }
292
293    /// Returns `true` if the link mode is [`LinkMode::Symlink`].
294    pub fn is_symlink(&self) -> bool {
295        matches!(self, Self::Symlink)
296    }
297}
298
299/// Extract a wheel by cloning all of its files into site packages. The files will be cloned
300/// via copy-on-write, which is similar to a hard link, but allows the files to be modified
301/// independently (that is, the file is copied upon modification).
302///
303/// This method uses `clonefile` on macOS, and `reflink` on Linux. See [`clone_recursive`] for
304/// details.
305fn clone_wheel_files(
306    site_packages: impl AsRef<Path>,
307    wheel: impl AsRef<Path>,
308    locks: &Locks,
309    filename: &WheelFilename,
310) -> Result<usize, Error> {
311    let wheel = wheel.as_ref();
312    let mut count = 0usize;
313    let mut attempt = Attempt::default();
314
315    for entry in fs::read_dir(wheel)? {
316        let entry = entry?;
317        locks.register_installed_path(
318            entry
319                .path()
320                .strip_prefix(wheel)
321                .expect("wheel path starts with wheel root"),
322            &entry.path(),
323            filename,
324        );
325        clone_recursive(site_packages.as_ref(), wheel, locks, &entry, &mut attempt)?;
326        count += 1;
327    }
328
329    // The directory mtime is not updated when cloning and the mtime is used by CPython's
330    // import mechanisms to determine if it should look for new packages in a directory.
331    // Here, we force the mtime to be updated to ensure that packages are importable without
332    // manual cache invalidation.
333    //
334    // <https://github.com/python/cpython/blob/8336cb2b6f428246803b02a4e97fce49d0bb1e09/Lib/importlib/_bootstrap_external.py#L1601>
335    let now = SystemTime::now();
336
337    match fs::File::open(site_packages.as_ref()) {
338        Ok(dir) => {
339            if let Err(err) = dir.set_modified(now) {
340                debug!(
341                    "Failed to update mtime for {}: {err}",
342                    site_packages.as_ref().display()
343                );
344            }
345        }
346        Err(err) => debug!(
347            "Failed to open {} to update mtime: {err}",
348            site_packages.as_ref().display()
349        ),
350    }
351
352    Ok(count)
353}
354
355// Hard linking / reflinking might not be supported but we (afaik) can't detect this ahead of time,
356// so we'll try hard linking / reflinking the first file - if this succeeds we'll know later
357// errors are not due to lack of os/fs support. If it fails, we'll switch to copying for the rest of the
358// install.
359#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
360enum Attempt {
361    #[default]
362    Initial,
363    Subsequent,
364    UseCopyFallback,
365}
366
367/// Recursively clone the contents of `from` into `to`.
368///
369/// Note the behavior here is platform-dependent.
370///
371/// On macOS, directories can be recursively copied with a single `clonefile` call. So we only
372/// need to iterate over the top-level of the directory, and copy each file or subdirectory
373/// unless the subdirectory exists already in which case we'll need to recursively merge its
374/// contents with the existing directory.
375///
376/// On Linux, we need to always reflink recursively, as `FICLONE` ioctl does not support
377/// directories. Also note, that reflink is only supported on certain filesystems (btrfs, xfs,
378/// ...), and only when it does not cross filesystem boundaries.
379///
380/// On Windows, we also always need to reflink recursively, as `FSCTL_DUPLICATE_EXTENTS_TO_FILE`
381/// ioctl is not supported on directories. Also, it is only supported on certain filesystems
382/// (ReFS, SMB, ...).
383fn clone_recursive(
384    site_packages: &Path,
385    wheel: &Path,
386    locks: &Locks,
387    entry: &DirEntry,
388    attempt: &mut Attempt,
389) -> Result<(), Error> {
390    // Determine the existing and destination paths.
391    let from = entry.path();
392    let to = site_packages.join(
393        from.strip_prefix(wheel)
394            .expect("wheel path starts with wheel root"),
395    );
396
397    trace!("Cloning {} to {}", from.display(), to.display());
398
399    if (cfg!(windows) || cfg!(target_os = "linux")) && from.is_dir() {
400        fs::create_dir_all(&to)?;
401        for entry in fs::read_dir(from)? {
402            clone_recursive(site_packages, wheel, locks, &entry?, attempt)?;
403        }
404        return Ok(());
405    }
406
407    match attempt {
408        Attempt::Initial => {
409            if let Err(err) = reflink::reflink(&from, &to) {
410                if err.kind() == std::io::ErrorKind::AlreadyExists {
411                    // If cloning or copying fails and the directory exists already, it must be
412                    // merged recursively.
413                    if entry.file_type()?.is_dir() {
414                        for entry in fs::read_dir(from)? {
415                            clone_recursive(site_packages, wheel, locks, &entry?, attempt)?;
416                        }
417                    } else {
418                        // If file already exists, overwrite it.
419                        let tempdir = tempdir_in(site_packages)?;
420                        let tempfile = tempdir.path().join(from.file_name().unwrap());
421                        if reflink::reflink(&from, &tempfile).is_ok() {
422                            fs::rename(&tempfile, to)?;
423                        } else {
424                            debug!(
425                                "Failed to clone `{}` to temporary location `{}`, attempting to copy files as a fallback",
426                                from.display(),
427                                tempfile.display(),
428                            );
429                            *attempt = Attempt::UseCopyFallback;
430                            synchronized_copy(&from, &to, locks)?;
431                        }
432                    }
433                } else {
434                    debug!(
435                        "Failed to clone `{}` to `{}`, attempting to copy files as a fallback",
436                        from.display(),
437                        to.display()
438                    );
439                    // Fallback to copying
440                    *attempt = Attempt::UseCopyFallback;
441                    clone_recursive(site_packages, wheel, locks, entry, attempt)?;
442                }
443            }
444        }
445        Attempt::Subsequent => {
446            if let Err(err) = reflink::reflink(&from, &to) {
447                if err.kind() == std::io::ErrorKind::AlreadyExists {
448                    // If cloning/copying fails and the directory exists already, it must be merged recursively.
449                    if entry.file_type()?.is_dir() {
450                        for entry in fs::read_dir(from)? {
451                            clone_recursive(site_packages, wheel, locks, &entry?, attempt)?;
452                        }
453                    } else {
454                        // If file already exists, overwrite it.
455                        let tempdir = tempdir_in(site_packages)?;
456                        let tempfile = tempdir.path().join(from.file_name().unwrap());
457                        reflink::reflink(&from, &tempfile)?;
458                        fs::rename(&tempfile, to)?;
459                    }
460                } else {
461                    return Err(Error::Reflink { from, to, err });
462                }
463            }
464        }
465        Attempt::UseCopyFallback => {
466            if entry.file_type()?.is_dir() {
467                fs::create_dir_all(&to)?;
468                for entry in fs::read_dir(from)? {
469                    clone_recursive(site_packages, wheel, locks, &entry?, attempt)?;
470                }
471            } else {
472                synchronized_copy(&from, &to, locks)?;
473            }
474            warn_user_once!(
475                "Failed to clone files; falling back to full copy. This may lead to degraded performance.\n         If the cache and target directories are on different filesystems, reflinking may not be supported.\n         If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning."
476            );
477        }
478    }
479
480    if *attempt == Attempt::Initial {
481        *attempt = Attempt::Subsequent;
482    }
483    Ok(())
484}
485
486/// Extract a wheel by copying all of its files into site packages.
487fn copy_wheel_files(
488    site_packages: impl AsRef<Path>,
489    wheel: impl AsRef<Path>,
490    locks: &Locks,
491    filename: &WheelFilename,
492) -> Result<usize, Error> {
493    let mut count = 0usize;
494
495    // Walk over the directory.
496    for entry in WalkDir::new(&wheel) {
497        let entry = entry?;
498        let path = entry.path();
499        let relative = path.strip_prefix(&wheel).expect("walkdir starts with root");
500        let out_path = site_packages.as_ref().join(relative);
501        locks.register_installed_path(relative, path, filename);
502
503        if entry.file_type().is_dir() {
504            fs::create_dir_all(&out_path)?;
505            continue;
506        }
507
508        synchronized_copy(path, &out_path, locks)?;
509
510        count += 1;
511    }
512
513    Ok(count)
514}
515
516/// Extract a wheel by hard-linking all of its files into site packages.
517fn hardlink_wheel_files(
518    site_packages: impl AsRef<Path>,
519    wheel: impl AsRef<Path>,
520    locks: &Locks,
521    filename: &WheelFilename,
522) -> Result<usize, Error> {
523    let mut attempt = Attempt::default();
524    let mut count = 0usize;
525
526    // Walk over the directory.
527    for entry in WalkDir::new(&wheel) {
528        let entry = entry?;
529        let path = entry.path();
530        let relative = path.strip_prefix(&wheel).expect("walkdir starts with root");
531        let out_path = site_packages.as_ref().join(relative);
532
533        locks.register_installed_path(relative, path, filename);
534
535        if entry.file_type().is_dir() {
536            fs::create_dir_all(&out_path)?;
537            continue;
538        }
539
540        // The `RECORD` file is modified during installation, so we copy it instead of hard-linking.
541        if path.ends_with("RECORD") {
542            synchronized_copy(path, &out_path, locks)?;
543            count += 1;
544            continue;
545        }
546
547        // Fallback to copying if hardlinks aren't supported for this installation.
548        match attempt {
549            Attempt::Initial => {
550                // Once https://github.com/rust-lang/rust/issues/86442 is stable, use that.
551                attempt = Attempt::Subsequent;
552                if let Err(err) = fs::hard_link(path, &out_path) {
553                    // If the file already exists, remove it and try again.
554                    if err.kind() == std::io::ErrorKind::AlreadyExists {
555                        debug!(
556                            "File already exists (initial attempt), overwriting: {}",
557                            out_path.display()
558                        );
559                        // Removing and recreating would lead to race conditions.
560                        let tempdir = tempdir_in(&site_packages)?;
561                        let tempfile = tempdir.path().join(entry.file_name());
562                        if fs::hard_link(path, &tempfile).is_ok() {
563                            fs_err::rename(&tempfile, &out_path)?;
564                        } else {
565                            debug!(
566                                "Failed to hardlink `{}` to `{}`, attempting to copy files as a fallback",
567                                out_path.display(),
568                                path.display()
569                            );
570                            synchronized_copy(path, &out_path, locks)?;
571                            attempt = Attempt::UseCopyFallback;
572                        }
573                    } else {
574                        debug!(
575                            "Failed to hardlink `{}` to `{}`, attempting to copy files as a fallback",
576                            out_path.display(),
577                            path.display()
578                        );
579                        synchronized_copy(path, &out_path, locks)?;
580                        attempt = Attempt::UseCopyFallback;
581                    }
582                }
583            }
584            Attempt::Subsequent => {
585                if let Err(err) = fs::hard_link(path, &out_path) {
586                    // If the file already exists, remove it and try again.
587                    if err.kind() == std::io::ErrorKind::AlreadyExists {
588                        debug!(
589                            "File already exists (subsequent attempt), overwriting: {}",
590                            out_path.display()
591                        );
592                        // Removing and recreating would lead to race conditions.
593                        let tempdir = tempdir_in(&site_packages)?;
594                        let tempfile = tempdir.path().join(entry.file_name());
595                        fs::hard_link(path, &tempfile)?;
596                        fs_err::rename(&tempfile, &out_path)?;
597                    } else {
598                        return Err(err.into());
599                    }
600                }
601            }
602            Attempt::UseCopyFallback => {
603                synchronized_copy(path, &out_path, locks)?;
604                warn_user_once!(
605                    "Failed to hardlink files; falling back to full copy. This may lead to degraded performance.\n         If the cache and target directories are on different filesystems, hardlinking may not be supported.\n         If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning."
606                );
607            }
608        }
609
610        count += 1;
611    }
612
613    Ok(count)
614}
615
616/// Extract a wheel by symbolically-linking all of its files into site packages.
617fn symlink_wheel_files(
618    site_packages: impl AsRef<Path>,
619    wheel: impl AsRef<Path>,
620    locks: &Locks,
621    filename: &WheelFilename,
622) -> Result<usize, Error> {
623    let mut attempt = Attempt::default();
624    let mut count = 0usize;
625
626    // Walk over the directory.
627    for entry in WalkDir::new(&wheel) {
628        let entry = entry?;
629        let path = entry.path();
630        let relative = path.strip_prefix(&wheel).unwrap();
631        let out_path = site_packages.as_ref().join(relative);
632
633        locks.register_installed_path(relative, path, filename);
634
635        if entry.file_type().is_dir() {
636            fs::create_dir_all(&out_path)?;
637            continue;
638        }
639
640        // The `RECORD` file is modified during installation, so we copy it instead of symlinking.
641        if path.ends_with("RECORD") {
642            synchronized_copy(path, &out_path, locks)?;
643            count += 1;
644            continue;
645        }
646
647        // Fallback to copying if symlinks aren't supported for this installation.
648        match attempt {
649            Attempt::Initial => {
650                // Once https://github.com/rust-lang/rust/issues/86442 is stable, use that.
651                attempt = Attempt::Subsequent;
652                if let Err(err) = create_symlink(path, &out_path) {
653                    // If the file already exists, remove it and try again.
654                    if err.kind() == std::io::ErrorKind::AlreadyExists {
655                        debug!(
656                            "File already exists (initial attempt), overwriting: {}",
657                            out_path.display()
658                        );
659                        // Removing and recreating would lead to race conditions.
660                        let tempdir = tempdir_in(&site_packages)?;
661                        let tempfile = tempdir.path().join(entry.file_name());
662                        if create_symlink(path, &tempfile).is_ok() {
663                            fs::rename(&tempfile, &out_path)?;
664                        } else {
665                            debug!(
666                                "Failed to symlink `{}` to `{}`, attempting to copy files as a fallback",
667                                out_path.display(),
668                                path.display()
669                            );
670                            synchronized_copy(path, &out_path, locks)?;
671                            attempt = Attempt::UseCopyFallback;
672                        }
673                    } else {
674                        debug!(
675                            "Failed to symlink `{}` to `{}`, attempting to copy files as a fallback",
676                            out_path.display(),
677                            path.display()
678                        );
679                        synchronized_copy(path, &out_path, locks)?;
680                        attempt = Attempt::UseCopyFallback;
681                    }
682                }
683            }
684            Attempt::Subsequent => {
685                if let Err(err) = create_symlink(path, &out_path) {
686                    // If the file already exists, remove it and try again.
687                    if err.kind() == std::io::ErrorKind::AlreadyExists {
688                        debug!(
689                            "File already exists (subsequent attempt), overwriting: {}",
690                            out_path.display()
691                        );
692                        // Removing and recreating would lead to race conditions.
693                        let tempdir = tempdir_in(&site_packages)?;
694                        let tempfile = tempdir.path().join(entry.file_name());
695                        create_symlink(path, &tempfile)?;
696                        fs::rename(&tempfile, &out_path)?;
697                    } else {
698                        return Err(err.into());
699                    }
700                }
701            }
702            Attempt::UseCopyFallback => {
703                synchronized_copy(path, &out_path, locks)?;
704                warn_user_once!(
705                    "Failed to symlink files; falling back to full copy. This may lead to degraded performance.\n         If the cache and target directories are on different filesystems, symlinking may not be supported.\n         If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning."
706                );
707            }
708        }
709
710        count += 1;
711    }
712
713    Ok(count)
714}
715
716/// Copy from `from` to `to`, ensuring that the parent directory is locked. Avoids simultaneous
717/// writes to the same file, which can lead to corruption.
718///
719/// See: <https://github.com/astral-sh/uv/issues/4831>
720fn synchronized_copy(from: &Path, to: &Path, locks: &Locks) -> std::io::Result<()> {
721    // Ensure we have a lock for the directory.
722    let dir_lock = {
723        let mut locks_guard = locks.copy_dir_locks.lock().unwrap();
724        locks_guard
725            .entry(to.parent().unwrap().to_path_buf())
726            .or_insert_with(|| Arc::new(Mutex::new(())))
727            .clone()
728    };
729
730    // Acquire a lock on the directory.
731    let _dir_guard = dir_lock.lock().unwrap();
732
733    // Copy the file, which will also set its permissions.
734    fs::copy(from, to)?;
735
736    Ok(())
737}
738
739#[cfg(unix)]
740fn create_symlink<P: AsRef<Path>, Q: AsRef<Path>>(original: P, link: Q) -> std::io::Result<()> {
741    fs_err::os::unix::fs::symlink(original, link)
742}
743
744#[cfg(windows)]
745fn create_symlink<P: AsRef<Path>, Q: AsRef<Path>>(original: P, link: Q) -> std::io::Result<()> {
746    if original.as_ref().is_dir() {
747        fs_err::os::windows::fs::symlink_dir(original, link)
748    } else {
749        fs_err::os::windows::fs::symlink_file(original, link)
750    }
751}