This is an automated email from the ASF dual-hosted git repository.

jbonofre pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/karaf.git


The following commit(s) were added to refs/heads/main by this push:
     new 39b9149ff0 Use atomic move instead of delete-then-rename in 
SimpleDownloadTask (#2841)
39b9149ff0 is described below

commit 39b9149ff02e2d64e87bbb0042aceef6d95d80d8
Author: Aditya Nikam <[email protected]>
AuthorDate: Thu Sep 10 20:21:20 2026 +0530

    Use atomic move instead of delete-then-rename in SimpleDownloadTask (#2841)
    
    SimpleDownloadTask.download() staged wrap:/blueprint:/spring: bundle
    URLs into a temp file, then did:
    
      if (file.exists() && !file.delete()) { throw ...; }
      tmpFile.renameTo(file);
    
    Two overlapping resolutions of the same URL (e.g. two feature
    installs, each with their own DownloadManager -- dedup only happens
    within one instance) can race: the second one's delete() removes the
    first one's just-written file right as a third reader opens it,
    producing an intermittent FileNotFoundException. This shows up as
    needing multiple restarts before a container boots cleanly.
    
    Replace the delete-then-rename with a single Files.move(...,
    ATOMIC_MOVE), falling back to a plain move if the filesystem doesn't
    support atomic moves. A concurrent reader then always sees either the
    old or the new file, never a momentarily missing one.
    
    Added SimpleDownloadTaskTest, which runs 200 rounds of 6 concurrent
    downloads racing on the same destination while a reader thread polls
    for the file transiently disappearing after having existed. On the
    old delete-then-rename code this fails with hundreds of thousands of
    missing-file observations; with the fix it's zero.
    
    Fixes #2808
    
    Signed-off-by: adityaanikam <[email protected]>
---
 .../internal/download/impl/SimpleDownloadTask.java |  18 ++-
 .../download/impl/SimpleDownloadTaskTest.java      | 158 +++++++++++++++++++++
 2 files changed, 171 insertions(+), 5 deletions(-)

diff --git 
a/features/core/src/main/java/org/apache/karaf/features/internal/download/impl/SimpleDownloadTask.java
 
b/features/core/src/main/java/org/apache/karaf/features/internal/download/impl/SimpleDownloadTask.java
index 548c234d80..454daaa1a2 100644
--- 
a/features/core/src/main/java/org/apache/karaf/features/internal/download/impl/SimpleDownloadTask.java
+++ 
b/features/core/src/main/java/org/apache/karaf/features/internal/download/impl/SimpleDownloadTask.java
@@ -22,7 +22,9 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.URL;
+import java.nio.file.AtomicMoveNotSupportedException;
 import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
 import java.util.concurrent.ScheduledExecutorService;
 
 import org.apache.karaf.util.StreamUtils;
@@ -80,12 +82,18 @@ public class SimpleDownloadTask extends 
AbstractRetryableDownloadTask {
                 StreamUtils.copy(is, os);
             }
 
-            if (file.exists() && !file.delete()) {
-                throw new IOException("Unable to delete file: " + 
file.toString());
-            }
             // check: this will move the file to CHILD_HOME root directory...
-            if (!tmpFile.renameTo(file)) {
-                throw new IOException("Unable to rename file " + 
tmpFile.toString() + " to " + file.toString());
+            // Move atomically instead of delete-then-rename: two overlapping 
downloads of the
+            // same URL (e.g. two feature installs, each with their own 
DownloadManager -- dedup
+            // only happens within one instance) can otherwise race, with the 
second download's
+            // delete() removing the first's just-written file while a third 
reader has it open
+            // (gh-2808). Files.move(..., ATOMIC_MOVE) makes the destination 
always either the old
+            // or the new content, never transiently missing.
+            try {
+                Files.move(tmpFile.toPath(), file.toPath(),
+                        StandardCopyOption.REPLACE_EXISTING, 
StandardCopyOption.ATOMIC_MOVE);
+            } catch (AtomicMoveNotSupportedException e) {
+                Files.move(tmpFile.toPath(), file.toPath(), 
StandardCopyOption.REPLACE_EXISTING);
             }
             return file;
         } catch (Exception ignore) {
diff --git 
a/features/core/src/test/java/org/apache/karaf/features/internal/download/impl/SimpleDownloadTaskTest.java
 
b/features/core/src/test/java/org/apache/karaf/features/internal/download/impl/SimpleDownloadTaskTest.java
new file mode 100644
index 0000000000..81da9c9b4a
--- /dev/null
+++ 
b/features/core/src/test/java/org/apache/karaf/features/internal/download/impl/SimpleDownloadTaskTest.java
@@ -0,0 +1,158 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.karaf.features.internal.download.impl;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+public class SimpleDownloadTaskTest {
+
+    private Path tempDir;
+    private ScheduledExecutorService executorService;
+    private String previousKarafData;
+
+    @Before
+    public void setUp() throws Exception {
+        tempDir = Files.createTempDirectory("SimpleDownloadTaskTest");
+        executorService = new ScheduledThreadPoolExecutor(1);
+        previousKarafData = System.getProperty("karaf.data");
+        System.setProperty("karaf.data", 
tempDir.resolve("karaf-data").toString());
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        executorService.shutdownNow();
+        if (previousKarafData != null) {
+            System.setProperty("karaf.data", previousKarafData);
+        } else {
+            System.clearProperty("karaf.data");
+        }
+        deleteRecursively(tempDir);
+    }
+
+    // Reproduces the race in gh-2808: two overlapping downloads of the same 
URL (e.g. two
+    // feature installs, each with their own DownloadManager) can start before 
either has written
+    // the destination file. With the old delete-then-renameTo sequence, 
whichever download loses
+    // the race deletes the file the other just wrote, and a concurrent reader 
can observe the
+    // destination transiently missing. Files.move(..., ATOMIC_MOVE) closes 
that window: the
+    // destination is always either the old or new content, never briefly 
absent.
+    @Test
+    public void 
concurrentDownloadsOfSameUrlNeverExposeATransientlyMissingFile() throws 
Exception {
+        File basePath = tempDir.resolve("basePath").toFile();
+        basePath.mkdirs();
+
+        File source = tempDir.resolve("source.jar").toFile();
+        try (FileOutputStream os = new FileOutputStream(source)) {
+            os.write("content".getBytes(StandardCharsets.UTF_8));
+        }
+        String url = source.toURI().toURL().toString();
+        File destination = new File(basePath, Integer.toHexString(new 
URL(url).toString().hashCode()) + "-source.jar");
+
+        int writers = 6;
+        int rounds = 200;
+        AtomicInteger missingFileObservations = new AtomicInteger();
+
+        ExecutorService pool = Executors.newFixedThreadPool(writers + 1);
+        try {
+            for (int round = 0; round < rounds; round++) {
+                Files.deleteIfExists(destination.toPath());
+
+                CountDownLatch ready = new CountDownLatch(writers);
+                CountDownLatch go = new CountDownLatch(1);
+                CountDownLatch done = new CountDownLatch(writers);
+                AtomicBoolean stopReader = new AtomicBoolean(false);
+
+                for (int i = 0; i < writers; i++) {
+                    pool.submit(() -> {
+                        ready.countDown();
+                        awaitUninterruptibly(go);
+                        try {
+                            new SimpleDownloadTask(executorService, url, 
basePath).download(null);
+                        } catch (Exception ignore) {
+                            // An individual attempt failing outright is not 
the bug under test --
+                            // only a *transiently missing* destination file 
is.
+                        } finally {
+                            done.countDown();
+                        }
+                    });
+                }
+                Future<?> reader = pool.submit(() -> {
+                    boolean everSeen = false;
+                    while (!stopReader.get()) {
+                        boolean exists = destination.exists();
+                        if (exists) {
+                            everSeen = true;
+                        } else if (everSeen) {
+                            missingFileObservations.incrementAndGet();
+                        }
+                    }
+                });
+
+                ready.await();
+                go.countDown();
+                done.await(10, TimeUnit.SECONDS);
+                stopReader.set(true);
+                reader.get(10, TimeUnit.SECONDS);
+            }
+        } finally {
+            pool.shutdownNow();
+        }
+
+        assertEquals(
+                "A concurrent reader observed the destination file transiently 
missing after it had "
+                        + "already been created once in the same round -- the 
delete-then-rename race "
+                        + "from gh-2808",
+                0, missingFileObservations.get());
+    }
+
+    private static void awaitUninterruptibly(CountDownLatch latch) {
+        try {
+            latch.await();
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static void deleteRecursively(Path path) throws Exception {
+        if (!Files.exists(path)) {
+            return;
+        }
+        Files.walk(path)
+                .sorted((a, b) -> b.compareTo(a))
+                .forEach(p -> p.toFile().delete());
+    }
+}

Reply via email to