From f201504036bc33ceb08b3e9f19df962cb3bbcc47 Mon Sep 17 00:00:00 2001 From: Fabian Meumertzheim Date: Sun, 25 Jan 2026 12:51:52 +0100 Subject: [PATCH 1/3] Fix disk cache failures on concurrent read-write access on Windows This applies the fix made to the download cache in 753dc9750714af581147f6aa338adeb07a9dcb57 to the disk cache. --- .../build/lib/bazel/repository/cache/BUILD | 1 + .../bazel/repository/cache/DownloadCache.java | 3 +- .../devtools/build/lib/remote/disk/BUILD | 1 + .../lib/remote/disk/DiskCacheClient.java | 32 ++++++- .../devtools/build/lib/remote/disk/BUILD | 1 + .../lib/remote/disk/DiskCacheClientTest.java | 88 +++++++++++++++++++ 6 files changed, 123 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/BUILD b/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/BUILD index b1af194574d9a2..1b82ee010fd868 100644 --- a/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/BUILD +++ b/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/BUILD @@ -23,6 +23,7 @@ java_library( deps = [ "//src/main/java/com/google/devtools/build/lib/server:idle_task", "//src/main/java/com/google/devtools/build/lib/util:file_system_lock", + "//src/main/java/com/google/devtools/build/lib/util:os", "//src/main/java/com/google/devtools/build/lib/vfs", "//third_party:guava", "//third_party:jsr305", diff --git a/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/DownloadCache.java b/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/DownloadCache.java index 0df447da839785..a8fc8f40357ca3 100644 --- a/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/DownloadCache.java +++ b/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/DownloadCache.java @@ -21,6 +21,7 @@ import com.google.common.hash.HashFunction; import com.google.common.hash.Hasher; import com.google.common.io.BaseEncoding; +import com.google.devtools.build.lib.util.OS; import com.google.devtools.build.lib.vfs.DigestHashFunction; import com.google.devtools.build.lib.vfs.FileAccessException; import com.google.devtools.build.lib.vfs.FileSystemUtils; @@ -251,7 +252,7 @@ private void storeCacheValue( // that another thread won the race to place the file in the cache. As the exception is rather // generic and could result from other failure types, we rethrow the exception if the cache // entry hasn't been created. - if (!cacheValue.exists()) { + if (OS.getCurrent() != OS.WINDOWS || !cacheValue.exists()) { throw e; } } diff --git a/src/main/java/com/google/devtools/build/lib/remote/disk/BUILD b/src/main/java/com/google/devtools/build/lib/remote/disk/BUILD index 962a5388d0ddf2..f0a182428d8104 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/disk/BUILD +++ b/src/main/java/com/google/devtools/build/lib/remote/disk/BUILD @@ -24,6 +24,7 @@ java_library( "//src/main/java/com/google/devtools/build/lib/remote/util:digest_utils", "//src/main/java/com/google/devtools/build/lib/server:idle_task", "//src/main/java/com/google/devtools/build/lib/util:file_system_lock", + "//src/main/java/com/google/devtools/build/lib/util:os", "//src/main/java/com/google/devtools/build/lib/vfs", "//third_party:flogger", "//third_party:guava", diff --git a/src/main/java/com/google/devtools/build/lib/remote/disk/DiskCacheClient.java b/src/main/java/com/google/devtools/build/lib/remote/disk/DiskCacheClient.java index f3a29c12a2d318..54ea0818cbf605 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/disk/DiskCacheClient.java +++ b/src/main/java/com/google/devtools/build/lib/remote/disk/DiskCacheClient.java @@ -35,6 +35,8 @@ import com.google.devtools.build.lib.remote.util.DigestOutputStream; import com.google.devtools.build.lib.remote.util.DigestUtil; import com.google.devtools.build.lib.remote.util.Utils; +import com.google.devtools.build.lib.util.OS; +import com.google.devtools.build.lib.vfs.FileAccessException; import com.google.devtools.build.lib.vfs.Path; import com.google.protobuf.ByteString; import com.google.protobuf.ExtensionRegistryLite; @@ -142,7 +144,20 @@ public void captureFile(Path src, Digest digest, Store store) throws IOException } target.getParentDirectory().createDirectoryAndParents(); - src.renameTo(target); + try { + src.renameTo(target); + } catch (FileAccessException e) { + // On Windows, atomically replacing a file that is currently opened (e.g. due to a + // concurrent get on the cache) results in renameTo throwing this exception, which wraps an + // AccessDeniedException. This case is benign since if the target path already exists, we + // know that another thread won the race to place the file in the cache. As the exception is + // rather generic and could result from other failure types, we rethrow the exception if the + // cache entry hasn't been created. + if (OS.getCurrent() != OS.WINDOWS || !target.exists()) { + throw e; + } + src.delete(); + } } private ListenableFuture download(Digest digest, OutputStream out, Store store) { @@ -333,7 +348,20 @@ public void saveFile(Digest digest, Store store, InputStream in) throws IOExcept } } path.getParentDirectory().createDirectoryAndParents(); - temp.renameTo(path); + try { + temp.renameTo(path); + } catch (FileAccessException e) { + // On Windows, atomically replacing a file that is currently opened (e.g. due to a + // concurrent get on the cache) results in renameTo throwing this exception, which wraps an + // AccessDeniedException. This case is benign since if the target path already exists, we + // know that another thread won the race to place the file in the cache. As the exception is + // rather generic and could result from other failure types, we rethrow the exception if the + // cache entry hasn't been created. + if (OS.getCurrent() != OS.WINDOWS || !path.exists()) { + throw e; + } + temp.delete(); + } } catch (IOException e) { try { temp.delete(); diff --git a/src/test/java/com/google/devtools/build/lib/remote/disk/BUILD b/src/test/java/com/google/devtools/build/lib/remote/disk/BUILD index b896aa4ee53708..2697bca70c5525 100644 --- a/src/test/java/com/google/devtools/build/lib/remote/disk/BUILD +++ b/src/test/java/com/google/devtools/build/lib/remote/disk/BUILD @@ -33,6 +33,7 @@ java_test( "//src/test/java/com/google/devtools/build/lib:test_runner", "//src/test/java/com/google/devtools/build/lib/testutil:TestUtils", "//src/test/java/com/google/devtools/build/lib/testutil:external_file_system_lock", + "//src/test/java/com/google/devtools/build/lib/vfs/util:util_internal", "//third_party:error_prone_annotations", "//third_party:guava", "//third_party:junit4", diff --git a/src/test/java/com/google/devtools/build/lib/remote/disk/DiskCacheClientTest.java b/src/test/java/com/google/devtools/build/lib/remote/disk/DiskCacheClientTest.java index 80072c2a7a753a..4e657c10c8c67a 100644 --- a/src/test/java/com/google/devtools/build/lib/remote/disk/DiskCacheClientTest.java +++ b/src/test/java/com/google/devtools/build/lib/remote/disk/DiskCacheClientTest.java @@ -32,6 +32,7 @@ import com.google.devtools.build.lib.remote.common.OutputDigestMismatchException; import com.google.devtools.build.lib.remote.common.RemoteCacheClient.ActionKey; import com.google.devtools.build.lib.remote.util.DigestUtil; +import com.google.devtools.build.lib.testutil.TestUtils; import com.google.devtools.build.lib.vfs.DigestHashFunction; import com.google.devtools.build.lib.vfs.FileSystem; import com.google.devtools.build.lib.vfs.FileSystemUtils; @@ -39,10 +40,17 @@ import com.google.devtools.build.lib.vfs.SyscallCache; import com.google.devtools.build.lib.vfs.bazel.BazelHashFunctions; import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem; +import com.google.devtools.build.lib.vfs.util.FileSystems; import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.protobuf.ByteString; import com.google.protobuf.Message; import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -346,6 +354,86 @@ public void downloadActionResult_withReferencedTreeFileMissing_returnsNull() thr assertThat(result).isNull(); } + @Test + public void concurrentUploadDownload() + throws IOException, ExecutionException, InterruptedException { + var nativeDiskCacheDir = TestUtils.createUniqueTmpDir(FileSystems.getNativeFileSystem()); + var nativeClient = + new DiskCacheClient(nativeDiskCacheDir, DIGEST_UTIL, /* verifyDownloads= */ false); + var tasks = new ArrayList>(); + // Use 1 MB blobs to increase the window for concurrent access during write/rename. + var contentSize = 1024 * 1024; + var numConcurrentOps = 10; + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + for (int attempt = 0; attempt < 100; attempt++) { + var contentArray = new byte[contentSize]; + // Fill with a pattern based on the attempt number. + for (int i = 0; i < contentSize; i++) { + contentArray[i] = (byte) (attempt + i); + } + var contentBytes = ByteString.copyFrom(contentArray); + var contentDigest = DIGEST_UTIL.compute(contentArray); + // Use a latch to ensure all concurrent tasks start at roughly the same time. + var startLatch = new CountDownLatch(numConcurrentOps); + // Half the tasks do uploads, half do downloads with a slow OutputStream to keep the file + // open longer. This maximizes the chance of a rename failing because a download has the + // file open. + for (int concurrentOp = 0; concurrentOp < numConcurrentOps; concurrentOp++) { + boolean isUploader = concurrentOp % 2 == 0; + tasks.add( + executor.submit( + () -> { + // Signal ready and wait for all tasks to be ready. + startLatch.countDown(); + startLatch.await(); + if (isUploader) { + getFromFuture(nativeClient.uploadBlob(contentDigest, contentBytes)); + } else { + // Use a slow OutputStream that pauses periodically to keep the file open + // longer during download. + var out = + new OutputStream() { + private int bytesWritten = 0; + + @Override + public void write(int b) throws IOException { + bytesWritten++; + maybeSleep(); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + bytesWritten += len; + maybeSleep(); + } + + private void maybeSleep() { + // Sleep every 64KB to slow down the download. + if (bytesWritten % (64 * 1024) < 100) { + try { + Thread.sleep(1); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + }; + try { + getFromFuture(nativeClient.downloadBlob(contentDigest, out)); + } catch (CacheNotFoundException ignored) { + // File not yet uploaded by another task. + } + } + return null; + })); + } + } + for (var task : tasks) { + task.get(); + } + } + } + private Tree getTreeWithFile(Digest fileDigest) { return Tree.newBuilder() .addChildren(Directory.newBuilder().addFiles(FileNode.newBuilder().setDigest(fileDigest))) From e0a92ad55cdf60e0a3d6b4c2af8dec8418b4c0b3 Mon Sep 17 00:00:00 2001 From: Fabian Meumertzheim Date: Wed, 28 Jan 2026 16:30:34 +0100 Subject: [PATCH 2/3] Address comment --- .../build/lib/bazel/repository/cache/BUILD | 1 - .../bazel/repository/cache/DownloadCache.java | 16 +-------- .../devtools/build/lib/remote/disk/BUILD | 1 - .../lib/remote/disk/DiskCacheClient.java | 33 ++--------------- .../build/lib/vfs/FileSystemUtils.java | 35 +++++++++++++++++++ 5 files changed, 39 insertions(+), 47 deletions(-) diff --git a/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/BUILD b/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/BUILD index 1b82ee010fd868..b1af194574d9a2 100644 --- a/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/BUILD +++ b/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/BUILD @@ -23,7 +23,6 @@ java_library( deps = [ "//src/main/java/com/google/devtools/build/lib/server:idle_task", "//src/main/java/com/google/devtools/build/lib/util:file_system_lock", - "//src/main/java/com/google/devtools/build/lib/util:os", "//src/main/java/com/google/devtools/build/lib/vfs", "//third_party:guava", "//third_party:jsr305", diff --git a/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/DownloadCache.java b/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/DownloadCache.java index a8fc8f40357ca3..c07e57a847c6d8 100644 --- a/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/DownloadCache.java +++ b/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/DownloadCache.java @@ -21,9 +21,7 @@ import com.google.common.hash.HashFunction; import com.google.common.hash.Hasher; import com.google.common.io.BaseEncoding; -import com.google.devtools.build.lib.util.OS; import com.google.devtools.build.lib.vfs.DigestHashFunction; -import com.google.devtools.build.lib.vfs.FileAccessException; import com.google.devtools.build.lib.vfs.FileSystemUtils; import com.google.devtools.build.lib.vfs.Path; import java.io.IOException; @@ -243,19 +241,7 @@ private void storeCacheValue( Path tmpName = cacheEntry.getRelative(TMP_PREFIX + UUID.randomUUID()); cacheEntry.createDirectoryAndParents(); fileWriter.writeTo(tmpName); - try { - tmpName.renameTo(cacheValue); - } catch (FileAccessException e) { - // On Windows, atomically replacing a file that is currently opened (e.g. due to a concurrent - // get on the cache) results in renameTo throwing this exception, which wraps an - // AccessDeniedException. This case is benign since if the target path already exists, we know - // that another thread won the race to place the file in the cache. As the exception is rather - // generic and could result from other failure types, we rethrow the exception if the cache - // entry hasn't been created. - if (OS.getCurrent() != OS.WINDOWS || !cacheValue.exists()) { - throw e; - } - } + FileSystemUtils.renameToleratingConcurrentCreation(tmpName, cacheValue); if (!Strings.isNullOrEmpty(canonicalId)) { String idHash = keyType.newHasher().putBytes(canonicalId.getBytes(UTF_8)).hash().toString(); diff --git a/src/main/java/com/google/devtools/build/lib/remote/disk/BUILD b/src/main/java/com/google/devtools/build/lib/remote/disk/BUILD index f0a182428d8104..962a5388d0ddf2 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/disk/BUILD +++ b/src/main/java/com/google/devtools/build/lib/remote/disk/BUILD @@ -24,7 +24,6 @@ java_library( "//src/main/java/com/google/devtools/build/lib/remote/util:digest_utils", "//src/main/java/com/google/devtools/build/lib/server:idle_task", "//src/main/java/com/google/devtools/build/lib/util:file_system_lock", - "//src/main/java/com/google/devtools/build/lib/util:os", "//src/main/java/com/google/devtools/build/lib/vfs", "//third_party:flogger", "//third_party:guava", diff --git a/src/main/java/com/google/devtools/build/lib/remote/disk/DiskCacheClient.java b/src/main/java/com/google/devtools/build/lib/remote/disk/DiskCacheClient.java index 54ea0818cbf605..13d5f317e703c0 100644 --- a/src/main/java/com/google/devtools/build/lib/remote/disk/DiskCacheClient.java +++ b/src/main/java/com/google/devtools/build/lib/remote/disk/DiskCacheClient.java @@ -35,8 +35,7 @@ import com.google.devtools.build.lib.remote.util.DigestOutputStream; import com.google.devtools.build.lib.remote.util.DigestUtil; import com.google.devtools.build.lib.remote.util.Utils; -import com.google.devtools.build.lib.util.OS; -import com.google.devtools.build.lib.vfs.FileAccessException; +import com.google.devtools.build.lib.vfs.FileSystemUtils; import com.google.devtools.build.lib.vfs.Path; import com.google.protobuf.ByteString; import com.google.protobuf.ExtensionRegistryLite; @@ -144,20 +143,7 @@ public void captureFile(Path src, Digest digest, Store store) throws IOException } target.getParentDirectory().createDirectoryAndParents(); - try { - src.renameTo(target); - } catch (FileAccessException e) { - // On Windows, atomically replacing a file that is currently opened (e.g. due to a - // concurrent get on the cache) results in renameTo throwing this exception, which wraps an - // AccessDeniedException. This case is benign since if the target path already exists, we - // know that another thread won the race to place the file in the cache. As the exception is - // rather generic and could result from other failure types, we rethrow the exception if the - // cache entry hasn't been created. - if (OS.getCurrent() != OS.WINDOWS || !target.exists()) { - throw e; - } - src.delete(); - } + FileSystemUtils.renameToleratingConcurrentCreation(src, target); } private ListenableFuture download(Digest digest, OutputStream out, Store store) { @@ -348,20 +334,7 @@ public void saveFile(Digest digest, Store store, InputStream in) throws IOExcept } } path.getParentDirectory().createDirectoryAndParents(); - try { - temp.renameTo(path); - } catch (FileAccessException e) { - // On Windows, atomically replacing a file that is currently opened (e.g. due to a - // concurrent get on the cache) results in renameTo throwing this exception, which wraps an - // AccessDeniedException. This case is benign since if the target path already exists, we - // know that another thread won the race to place the file in the cache. As the exception is - // rather generic and could result from other failure types, we rethrow the exception if the - // cache entry hasn't been created. - if (OS.getCurrent() != OS.WINDOWS || !path.exists()) { - throw e; - } - temp.delete(); - } + FileSystemUtils.renameToleratingConcurrentCreation(temp, path); } catch (IOException e) { try { temp.delete(); diff --git a/src/main/java/com/google/devtools/build/lib/vfs/FileSystemUtils.java b/src/main/java/com/google/devtools/build/lib/vfs/FileSystemUtils.java index 345c969b536f03..ae7fd230922524 100644 --- a/src/main/java/com/google/devtools/build/lib/vfs/FileSystemUtils.java +++ b/src/main/java/com/google/devtools/build/lib/vfs/FileSystemUtils.java @@ -25,6 +25,7 @@ import com.google.common.io.ByteStreams; import com.google.devtools.build.lib.concurrent.ThreadSafety.ConditionallyThreadSafe; import com.google.devtools.build.lib.concurrent.ThreadSafety.ThreadSafe; +import com.google.devtools.build.lib.util.OS; import com.google.devtools.build.lib.util.StringEncoding; import java.io.FileNotFoundException; import java.io.IOException; @@ -489,6 +490,40 @@ public static MoveResult moveFile(Path from, Path to) throws IOException { } } + /** + * Atomically renames a source file to a target file, tolerating the case where another thread has + * concurrently created the target file (e.g. because it is known to have the same content in a + * CAS-like structure). + * + *

This handles a Windows-specific edge case: when the target file is being read by another + * process (e.g., during a concurrent cache lookup), the rename operation fails with a {@link + * FileAccessException}. If the target file already exists when this happens, it means another + * thread won the race to create it, so we can safely delete the source file. + * + *

The parent directories of the target file must already exist. + * + * @param source the file to rename + * @param target the destination path + */ + @ThreadSafe + public static void renameToleratingConcurrentCreation(Path source, Path target) + throws IOException { + try { + source.renameTo(target); + } catch (FileAccessException e) { + // On Windows, atomically replacing a file that is currently opened (e.g. due to a concurrent + // get on the cache) results in renameTo throwing this exception, which wraps an + // AccessDeniedException. This case is benign since if the target path already exists, we know + // that another thread won the race to place the file in the cache. As the exception is rather + // generic and could result from other failure types, we rethrow the exception if the cache + // entry hasn't been created. + if (OS.getCurrent() != OS.WINDOWS || !target.exists()) { + throw e; + } + source.delete(); + } + } + /* Directory tree operations. */ /** From e6c03a5eb111db3cf061ac4f62762cfa100f77e4 Mon Sep 17 00:00:00 2001 From: Fabian Meumertzheim Date: Wed, 28 Jan 2026 16:38:09 +0100 Subject: [PATCH 3/3] Add tests --- .../build/lib/vfs/FileSystemUtilsTest.java | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/src/test/java/com/google/devtools/build/lib/vfs/FileSystemUtilsTest.java b/src/test/java/com/google/devtools/build/lib/vfs/FileSystemUtilsTest.java index c401dad4c5b46c..524c8605fa6fd2 100644 --- a/src/test/java/com/google/devtools/build/lib/vfs/FileSystemUtilsTest.java +++ b/src/test/java/com/google/devtools/build/lib/vfs/FileSystemUtilsTest.java @@ -20,6 +20,7 @@ import static com.google.devtools.build.lib.vfs.FileSystemUtils.moveFile; import static com.google.devtools.build.lib.vfs.FileSystemUtils.relativePath; import static com.google.devtools.build.lib.vfs.FileSystemUtils.removeExtension; +import static com.google.devtools.build.lib.vfs.FileSystemUtils.renameToleratingConcurrentCreation; import static com.google.devtools.build.lib.vfs.FileSystemUtils.touchFile; import static com.google.devtools.build.lib.vfs.FileSystemUtils.traverseTree; import static java.nio.charset.StandardCharsets.ISO_8859_1; @@ -1016,6 +1017,97 @@ public void testCreateHardLinkForNonEmptyDirectory_success() throws Exception { .isEqualTo(fileSystem.stat(originalPath3.asFragment(), false).getNodeId()); } + @Test + public void testRenameToleratingConcurrentCreation_success() throws Exception { + Path source = fileSystem.getPath("/source"); + Path target = fileSystem.getPath("/target"); + target.getParentDirectory().createDirectoryAndParents(); + + FileSystemUtils.writeContent(source, UTF_8, "hello world"); + + renameToleratingConcurrentCreation(source, target); + + assertThat(source.exists()).isFalse(); + assertThat(target.exists()).isTrue(); + assertThat(FileSystemUtils.readContent(target, UTF_8)).isEqualTo("hello world"); + } + + @Test + public void testRenameToleratingConcurrentCreation_sourceDoesNotExist() throws Exception { + Path source = fileSystem.getPath("/source"); + Path target = fileSystem.getPath("/target"); + target.getParentDirectory().createDirectoryAndParents(); + + assertThrows( + FileNotFoundException.class, () -> renameToleratingConcurrentCreation(source, target)); + } + + @Test + public void testRenameToleratingConcurrentCreation_targetParentDoesNotExist() throws Exception { + Path source = fileSystem.getPath("/source"); + Path target = fileSystem.getPath("/nonexistent/target"); + + FileSystemUtils.writeContent(source, UTF_8, "hello world"); + + assertThrows( + FileNotFoundException.class, () -> renameToleratingConcurrentCreation(source, target)); + } + + @Test + public void testRenameToleratingConcurrentCreation_toleratesFileAccessExceptionOnWindows() + throws Exception { + FileSystem fs = new FileAccessExceptionOnRenameFS(); + Path source = fs.getPath("/source"); + Path target = fs.getPath("/target"); + source.getParentDirectory().createDirectoryAndParents(); + target.getParentDirectory().createDirectoryAndParents(); + + FileSystemUtils.writeContent(source, UTF_8, "hello world"); + FileSystemUtils.writeContent(target, UTF_8, "existing content"); + + if (OS.getCurrent() == OS.WINDOWS) { + renameToleratingConcurrentCreation(source, target); + + assertThat(source.exists()).isFalse(); + assertThat(target.exists()).isTrue(); + assertThat(FileSystemUtils.readContent(target, UTF_8)).isEqualTo("existing content"); + } else { + assertThrows( + FileAccessException.class, () -> renameToleratingConcurrentCreation(source, target)); + + assertThat(source.exists()).isTrue(); + } + } + + @Test + public void testRenameToleratingConcurrentCreation_throwsIfTargetDoesNotExistAfterException() + throws Exception { + FileSystem fs = new FileAccessExceptionOnRenameFS(); + Path source = fs.getPath("/source"); + Path target = fs.getPath("/target"); + source.getParentDirectory().createDirectoryAndParents(); + target.getParentDirectory().createDirectoryAndParents(); + + FileSystemUtils.writeContent(source, UTF_8, "hello world"); + + assertThrows( + FileAccessException.class, () -> renameToleratingConcurrentCreation(source, target)); + + assertThat(source.exists()).isTrue(); + } + + /** A file system that throws FileAccessException on rename, simulating Windows behavior. */ + static class FileAccessExceptionOnRenameFS extends InMemoryFileSystem { + FileAccessExceptionOnRenameFS() { + super(DigestHashFunction.SHA256); + } + + @Override + public void renameTo(PathFragment source, PathFragment target) throws IOException { + throw new FileAccessException("Access denied (simulated Windows behavior)"); + } + } + static class MultipleDeviceFS extends InMemoryFileSystem { MultipleDeviceFS() { super(DigestHashFunction.SHA256);