Repository: vercel/next.js
Pull request: Turbopack: Tweak retry loop for link creation to try to fix os error 80 on Windows

Though I've been unable to reproduce the issue (prior PRs in this stack are my attempts at that), I believe this *should* fix the OS Error 80 ([`ERROR_FILE_EXISTS`](https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-)) that we've seen users report on Windows: https://github.com/vercel/next.js/discussions/88382

Basically instead of deleting the existing link in a retry loop, and then trying to create new new link using a retry loop, we should instead:
- Merge these two retry loops into one, in case another process creates the link after we delete it. This shouldn't typically happen, but could theoretically happen.
- Extend the retry logic to also retry on [`ErrorKind::AlreadyExists`](https://github.com/rust-lang/rust/blob/503745e9170b40841611aaaa634641edffd00b29/library/std/src/sys/io/error/windows.rs#L19). This might be possible if the filesystem is eventually consistent (which can happen on Windows), but I wasn't able to reproduce this.

There's a similar -- but different -- issue that users have been reporting where they get OS Error 1 (`ERROR_INVALID_FUNCTION`) on Windows. I believe this happens if we try to create a junction point on a non-NTFS filesystem. In that case, we need to report a clearer issue to the user instead of bubbling this up as an internal Turbopack error.

I tested this on Windows with the symlink fuzzer from #88667, as well as `cargo test -p turbo-tasks-fs`:

![Screenshot 2026-01-16 at 2.16.23 PM.png](https://app.graphite.com/user-attachments/assets/5c86e09b-c5e5-4ff2-9d89-da009917a018.png)

Changed files:
filename: Cargo.lock
patch: @@ -9544,6 +9544,7 @@ dependencies = [
  "serde_path_to_error",
  "sha2",
  "tempfile",
+ "thiserror 1.0.69",
  "tokio",
  "tracing",
  "triomphe 0.1.12",

filename: turbopack/crates/turbo-tasks-fs/Cargo.toml
patch: @@ -43,6 +43,7 @@ rustc-hash = { workspace = true }
 serde = { workspace = true, features = ["rc"] }
 serde_json = { workspace = true }
 serde_path_to_error = { workspace = true }
+thiserror = { workspace = true }
 tokio = { workspace = true }
 tracing = { workspace = true }
 triomphe = { workspace = true }

filename: turbopack/crates/turbo-tasks-fs/src/lib.rs
patch: @@ -80,7 +80,7 @@ use crate::{
     json::UnparsableJson,
     mutex_map::MutexMap,
     read_glob::{read_glob, track_glob},
-    retry::retry_blocking,
+    retry::{can_retry, retry_blocking, retry_blocking_custom},
     rope::{Rope, RopeReader},
     util::extract_disk_access,
     watcher::DiskWatcher,
@@ -1139,65 +1139,87 @@ impl FileSystem for DiskFileSystem {
                         })?;
                     }
 
-                    if old_content.is_some() {
-                        // Remove existing symlink before creating a new one. On Unix, symlink(2)
-                        // fails with EEXIST if the link already exists instead of overwriting it.
-                        // Windows has similar behavior with junction points.
-                        remove_symbolic_link_dir_helper(&full_path)
-                            .instrument(tracing::info_span!(
-                                "remove existing symlink before write",
-                                name = ?full_path,
-                            ))
-                            .concurrency_limited(&inner.write_semaphore)
-                            .await
-                            .with_context(|| {
-                                format!("removing existing symlink {full_path:?} failed")
-                            })?;
+                    #[derive(thiserror::Error, Debug)]
+                    #[error("{msg}: {source}")]
+                    struct SymlinkCreationError {
+                        msg: &'static str,
+                        #[source]
+                        source: io::Error,
                     }
 
-                    retry_blocking(|| {
-                        #[cfg(not(windows))]
-                        {
-                            std::os::unix::fs::symlink(&target, &full_path)
+                    let mut has_old_content = old_content.is_some();
+                    let try_create_link = || {
+                        if has_old_content {
+                            // Remove existing symlink before creating a new one. On Unix,
+                            // symlink(2) fails with EEXIST if the link already exists instead of
+                            // overwriting it. Windows has similar behavior with junction points.
+                            remove_symbolic_link_dir_helper(&full_path).map_err(|err| {
+                                SymlinkCreationError {
+                                    msg: "removal of existing symbolic link or junction point \
+                                          failed",
+                                    source: err,
+                                }
+                            })?;
+                            has_old_content = false;
                         }
+                        #[cfg(not(windows))]
+                        let io_result = std::os::unix::fs::symlink(&target, &full_path);
                         #[cfg(windows)]
-                        {
-                            if is_directory {
-                                std::os::windows::fs::junction_point(&target, &full_path)
-                            } else {
-                                std::os::windows::fs::symlink_file(&target, &full_path)
+                        let io_result = if is_directory {
+                            std::os::windows::fs::junction_point(&target, &full_path)
+                        } else {
+                            std::os::windows::fs::symlink_file(&target, &full_path)
+                        };
+                        io_result.map_err(|err| {
+                            if err.kind() == ErrorKind::AlreadyExists {
+                                // try to remove the symlink on the next iteration of the loop
+                                has_old_content = true;
                             }
-                        }
-                    })
-                    .instrument(tracing::info_span!(
-                        "write symlink",
-                        name = ?full_path,
-                        target = ?target,
-                    ))
-                    .concurrency_limited(&inner.write_semaphore)
-                    .await
-                    .with_context(|| {
+                            SymlinkCreationError {
+                                msg: "creation of a new symbolic link or junction point failed",
+                                source: err,
+                            }
+                        })
+                    };
+                    fn can_retry_link(err: &SymlinkCreationError) -> bool {
+                        err.source.kind() == ErrorKind::AlreadyExists || can_retry(&err.source)
+                    }
+                    let err_context = || {
                         #[cfg(not(windows))]
-                        let message = format!("failed to create symlink to {target:?}");
+                        let message = format!(
+                            "failed to create symlink at {full_path:?} pointing to {target:?}"
+                        );
                         #[cfg(windows)]
                         let message = if is_directory {
-                            format!("failed to create junction point to {target:?}")
+                            format!(
+                                "failed to create junction point at {full_path:?} pointing to \
+                                 {target:?}"
+                            )
                         } else {
                             format!(
-                                "failed to create symlink to {target:?}\n\
+                                "failed to create symlink at {full_path:?} pointing to {target:?}\n\
                                 (Note: creating file symlinks on Windows require developer mode or \
                                 admin permissions: \
                                 https://learn.microsoft.com/en-us/windows/advanced-settings/developer-mode)",
                             )
                         };
                         message
-                    })?;
+                    };
+                    retry_blocking_custom(try_create_link, can_retry_link)
+                        .instrument(tracing::info_span!(
+                            "write symlink",
+                            name = ?full_path,
+                            target = ?target,
+                        ))
+                        .concurrency_limited(&inner.write_semaphore)
+                        .await
+                        .with_context(err_context)?;
                 }
                 OsSpecificLinkContent::Invalid => {
                     bail!("invalid symlink target: {full_path:?}")
                 }
                 OsSpecificLinkContent::NotFound => {
-                    remove_symbolic_link_dir_helper(&full_path)
+                    retry_blocking(|| remove_symbolic_link_dir_helper(&full_path))
                         .instrument(tracing::info_span!("remove symlink", name = ?full_path))
                         .concurrency_limited(&inner.write_semaphore)
                         .await
@@ -1236,41 +1258,35 @@ impl FileSystem for DiskFileSystem {
     }
 }
 
-async fn remove_symbolic_link_dir_helper(path: &Path) -> Result<()> {
-    retry_blocking(|| {
-        if cfg!(windows) {
-            // Junction points on Windows are treated as directories, and therefore need
-            // `remove_dir`:
-            //
-            // > `RemoveDirectory` can be used to remove a directory junction. Since the target
-            // > directory and its contents will remain accessible through its canonical path, the
-            // > target directory itself is not affected by removing a junction which targets it.
-            //
-            // -- https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-removedirectoryw
-            //
-            // However, Next 16.1.0 shipped with symlinks, before we switched to junction links on
-            // Windows, and `remove_dir` won't work on symlinks. So try to remove it as a directory
-            // (junction) first, and then fall back to removing it as a file (symlink).
-            std::fs::remove_dir(path).or_else(|err| {
-                if err.kind() == ErrorKind::NotADirectory {
-                    std::fs::remove_file(path)
-                } else {
-                    Err(err)
-                }
-            })
-        } else {
-            std::fs::remove_file(path)
-        }
-    })
-    .await
-    .or_else(|err| {
-        if err.kind() == ErrorKind::NotFound {
-            Ok(())
-        } else {
-            Err(err)
-        }
-    })
-    .with_context(|| format!("removing existing symlink {path:?} failed"))
+fn remove_symbolic_link_dir_helper(path: &Path) -> io::Result<()> {
+    let result = if cfg!(windows) {
+        // Junction points on Windows are treated as directories, and therefore need
+        // `remove_dir`:
+        //
+        // > `RemoveDirectory` can be used to remove a directory junction. Since the target
+        // > directory and its contents will remain accessible through its canonical path, the
+        // > target directory itself is not affected by removing a junction which targets it.
+        //
+        // -- https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-removedirectoryw
+        //
+        // However, Next 16.1.0 shipped with symlinks, before we switched to junction links on
+        // Windows, and `remove_dir` won't work on symlinks. So try to remove it as a directory
+        // (junction) first, and then fall back to removing it as a file (symlink).
+        std::fs::remove_dir(path).or_else(|err| {
+            if err.kind() == ErrorKind::NotADirectory {
+                std::fs::remove_file(path)
+            } else {
+                Err(err)
+            }
+        })
+    } else {
+        std::fs::remove_file(path)
+    };
+    match result {
+        Ok(()) => Ok(()),
+        Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
+        Err(err) => Err(err),
+    }
 }
 
 #[turbo_tasks::value_impl]