+ * Normally the content visible at the target path is either the old file or the complete new file, never a + * partially written one. This method attempts the rename a bounded number of times ({@link #WINDOWS_MOVE_ATTEMPTS}; + * file locking by virus scanners, indexers or concurrent readers is transient on Windows), and if the file system + * refuses an atomic move (for example when source and target are on different stores), the source is first staged + * into a collocated temporary file next to the target and then renamed into place. *
- * Historically, on Windows the final move was implemented by opening the target path with a truncating stream - * and copying the source into it ({@link #classicCopy(Path, Path)}). That left a window during which a - * concurrent reader (for example a forked JVM resolving the same artifact) or an untimely process kill would - * observe, or durably leave behind, a truncated file at the final path - after checksum validation has already - * happened, so the torn content would from then on be trusted as verified. Instead, this method attempts the - * rename a bounded number of times ({@link #WINDOWS_MOVE_ATTEMPTS}, file locking by virus scanners, indexers or - * concurrent readers is transient on Windows), and if the file system refuses an atomic move (for example when - * source and target are on different stores), the source is first staged into a collocated temporary file next - * to the target and then renamed into place - the target path itself is never opened for writing. + * Last-resort fallback (Windows / IDE lock): When all rename attempts are exhausted with + * {@link AccessDeniedException} — typically because an IDE such as IntelliJ keeps the target {@code .jar} open + * continuously — and {@link #WINDOWS_CLASSIC_COPY_FALLBACK} is {@code true} (the default), the method falls back + * to a direct stream copy over the target path ({@link #classicCopy(Path, Path)}). On Windows, opening a file for + * writing via a stream succeeds even if another process holds a shared read handle, so the build completes. The + * atomicity guarantee is lost for this edge case: a concurrent reader may observe a partially written file. Set + * the system property {@code org.eclipse.aether.spi.io.PathProcessor.WINDOWS_CLASSIC_COPY_FALLBACK=false} to + * disable this fallback and propagate the {@link AccessDeniedException} instead. */ protected void retryingMove(Path source, Path target, StandardCopyOption[] copyOptions) throws IOException { FileSystemException lastException = null; @@ -297,6 +318,23 @@ protected void retryingMove(Path source, Path target, StandardCopyOption[] copyO Files.deleteIfExists(staged); } } + // All rename attempts failed with AccessDeniedException (persistent lock, e.g. IDE holding the jar open). + // As a last resort, fall back to a direct stream copy over the target. On Windows this succeeds even when + // another process holds a shared read handle because the stream open uses FILE_SHARE_READ semantics, + // unlike MoveFileEx/ReplaceFile which require exclusive access. The atomicity guarantee is lost here. + if (isClassicCopyFallbackEnabled() && (lastException instanceof AccessDeniedException)) { + LOGGER.warn( + "Atomic rename of {} to {} failed after {} attempt(s) ({}); falling back to direct copy." + + " Another process may be holding the target file open." + + " Set -D{}.WINDOWS_CLASSIC_COPY_FALLBACK=false to disable this fallback.", + source, + target, + WINDOWS_MOVE_ATTEMPTS, + lastException.getMessage(), + PathProcessor.class.getName()); + classicCopy(source, target); + return; + } throw lastException; } @@ -308,8 +346,17 @@ protected void fileSystemMove(Path source, Path target, StandardCopyOption... co } /** - * Pre-NIO2 way to copy files. Important: this method must never be pointed at a "final" (published) path, as it - * opens the target with truncation; callers stage into a temporary file and rename into place instead. + * Returns whether the classic-copy fallback is enabled. Exposed as a protected method to allow subclasses + * (and tests) to override the behavior controlled by {@link #WINDOWS_CLASSIC_COPY_FALLBACK}. + */ + protected boolean isClassicCopyFallbackEnabled() { + return WINDOWS_CLASSIC_COPY_FALLBACK; + } + + /** + * Pre-NIO2 way to copy files. Opens the target with truncation; normally callers stage into a temporary file and + * rename into place to avoid exposing partially written content. Used as a last resort on Windows when all atomic + * rename attempts are exhausted due to a persistent file lock (see {@link #retryingMove}). */ protected void classicCopy(Path source, Path target) throws IOException { ByteBuffer buffer = ByteBuffer.allocate(1024 * 32); diff --git a/maven-resolver-spi/src/test/java/org/eclipse/aether/spi/io/PathProcessorSupportTest.java b/maven-resolver-spi/src/test/java/org/eclipse/aether/spi/io/PathProcessorSupportTest.java index ef845a1d5..24d165970 100644 --- a/maven-resolver-spi/src/test/java/org/eclipse/aether/spi/io/PathProcessorSupportTest.java +++ b/maven-resolver-spi/src/test/java/org/eclipse/aether/spi/io/PathProcessorSupportTest.java @@ -104,8 +104,9 @@ protected void fileSystemMove(Path source, Path target, StandardCopyOption... co } @Test - void retryingMoveNeverTruncatesTargetOnFailure(@TempDir Path tempDir) throws IOException { + void retryingMoveNeverTruncatesTargetOnFailureWhenFallbackDisabled(@TempDir Path tempDir) throws IOException { AtomicInteger attempts = new AtomicInteger(); + // Subclass that disables the classic-copy fallback so the strict no-truncate guarantee is maintained PathProcessorSupport subject = new PathProcessorSupport() { @Override protected void fileSystemMove(Path source, Path target, StandardCopyOption... copyOptions) @@ -113,6 +114,11 @@ protected void fileSystemMove(Path source, Path target, StandardCopyOption... co attempts.incrementAndGet(); throw new AccessDeniedException(target.toString()); } + + @Override + protected boolean isClassicCopyFallbackEnabled() { + return false; + } }; Path source = write(tempDir, "source.txt", "new content"); Path target = write(tempDir, "target.txt", "old content"); @@ -126,6 +132,29 @@ protected void fileSystemMove(Path source, Path target, StandardCopyOption... co assertEquals("new content", read(source)); } + @Test + void retryingMoveFallsBackToClassicCopyOnPersistentLock(@TempDir Path tempDir) throws IOException { + AtomicInteger attempts = new AtomicInteger(); + // Simulate an IDE holding the target .jar open — all rename attempts fail with AccessDeniedException. + // The classic-copy fallback is enabled (default), so the build should complete. + PathProcessorSupport subject = new PathProcessorSupport() { + @Override + protected void fileSystemMove(Path source, Path target, StandardCopyOption... copyOptions) + throws IOException { + attempts.incrementAndGet(); + throw new AccessDeniedException(target.toString()); + } + }; + Path source = write(tempDir, "source.txt", "new content"); + Path target = write(tempDir, "target.txt", "old content"); + + // Should succeed via the classic-copy fallback, not throw + subject.retryingMove(source, target, ATOMIC_OPTIONS); + + assertEquals(PathProcessorSupport.WINDOWS_MOVE_ATTEMPTS, attempts.get(), "all retry attempts must be made"); + assertEquals("new content", read(target), "target must contain the new content after classic-copy fallback"); + } + @Test void retryingMoveStagesCollocatedCopyWhenAtomicMoveNotSupported(@TempDir Path tempDir) throws IOException { PathProcessorSupport subject = new PathProcessorSupport() {