Repository: canonical/multipass (2026-04-10T03:11:53Z)

Issue:
[image-vault] Image vault deletes in progress download
**Describe the bug**
If the image vault happens to hit its timer for pruning expired images while an image is being downloaded, the temporary file of the downloading image will be deleted. The image will continue to download, but the VM process will crash when it tries to use the image.

**To Reproduce**
1. Reduce the `image_refresh_timer` down to a minute (shorter than it takes to download an image).
2. Recompile Multipass
3. Try to launch an instance using an image you haven't used before (causes a download to occur).
4. VM process crashes when it finishes the download and tries to use the image.

**Expected behavior**
The in progress downloading image does not get deleted.

**Additional info**
- Should happen on any platform or backend.


Discussion:

Patch:
diff --git a/src/daemon/default_vm_image_vault.cpp b/src/daemon/default_vm_image_vault.cpp
index bb89cefef4..d0fd042142 100644
--- a/src/daemon/default_vm_image_vault.cpp
+++ b/src/daemon/default_vm_image_vault.cpp
@@ -311,7 +311,7 @@ mp::VMImage mp::DefaultVMImageVault::fetch_image(const FetchType& fetch_type,
                               prepare,
                               monitor));
 
-                in_progress_image_fetches[id] = future;
+                in_progress_image_fetches[id] = {image_dir, future};
             }
         }
         else
@@ -363,7 +363,7 @@ mp::VMImage mp::DefaultVMImageVault::fetch_image(const FetchType& fetch_type,
                               prepare,
                               monitor));
 
-                in_progress_image_fetches[id] = future;
+                in_progress_image_fetches[id] = {image_dir, future};
             }
         }
 
@@ -426,7 +426,12 @@ void mp::DefaultVMImageVault::prune_expired_images()
                          [&entry](const std::pair<std::string, VaultRecord>& record) {
                              return MP_PLATFORM.path_to_qstr(record.second.image.image_path)
                                  .contains(entry.absoluteFilePath());
-                         }) == prepared_image_records.cend())
+                         }) == prepared_image_records.cend() &&
+            !std::any_of(in_progress_image_fetches.cbegin(),
+                         in_progress_image_fetches.cend(),
+                         [&entry](const auto& fetch) {
+                             return fetch.second.first == entry.absoluteFilePath();
+                         }))
         {
             mpl::info(category,
                       "Source image {} is no longer valid. Removing it from the cache.",
@@ -662,7 +667,7 @@ std::optional<QFuture<mp::VMImage>> mp::DefaultVMImageVault::get_image_future(co
     auto it = in_progress_image_fetches.find(id);
     if (it != in_progress_image_fetches.end())
     {
-        return it->second;
+        return it->second.second;
     }
 
     return std::nullopt;
diff --git a/src/daemon/default_vm_image_vault.h b/src/daemon/default_vm_image_vault.h
index 10826e3df2..84af5d94db 100644
--- a/src/daemon/default_vm_image_vault.h
+++ b/src/daemon/default_vm_image_vault.h
@@ -98,7 +98,7 @@ class DefaultVMImageVault final : public BaseVMImageVault
 
     std::unordered_map<std::string, VaultRecord> prepared_image_records;
     std::unordered_map<std::string, VaultRecord> instance_image_records;
-    std::unordered_map<std::string, QFuture<VMImage>> in_progress_image_fetches;
+    std::unordered_map<std::string, std::pair<QString, QFuture<VMImage>>> in_progress_image_fetches;
 };
 
 void tag_invoke(const boost::json::value_from_tag&,


Test patch:
diff --git a/tests/unit/test_image_vault.cpp b/tests/unit/test_image_vault.cpp
index c374d5f4f4..1958cfeb58 100644
--- a/tests/unit/test_image_vault.cpp
+++ b/tests/unit/test_image_vault.cpp
@@ -44,6 +44,8 @@
 #include <QThread>
 #include <QUrl>
 
+#include <future>
+
 namespace mp = multipass;
 namespace mpl = multipass::logging;
 namespace mpt = multipass::test;
@@ -128,6 +130,32 @@ struct RunningURLDownloader : public mp::URLDownloader
     }
 };
 
+struct BlockingURLDownloader : public mp::URLDownloader
+{
+    BlockingURLDownloader() : mp::URLDownloader{std::chrono::seconds(10)}
+    {
+    }
+
+    void download_to(const QUrl&,
+                     const QString& file_name,
+                     int64_t,
+                     const int,
+                     const mp::ProgressMonitor&) override
+    {
+        started.set_value();
+        proceed.get_future().wait();
+        mpt::make_file_with_content(file_name, "");
+    }
+
+    QByteArray download(const QUrl&) override
+    {
+        return {};
+    }
+
+    std::promise<void> started;
+    std::promise<void> proceed;
+};
+
 struct ImageVault : public testing::Test
 {
     void SetUp()
@@ -711,6 +739,41 @@ TEST_F(ImageVault, imageExistsNotExpired)
     EXPECT_TRUE(QFileInfo::exists(file_name));
 }
 
+TEST_F(ImageVault, inProgressDownloadDirectoryNotPrunedDuringFetch)
+{
+    BlockingURLDownloader blocking_downloader;
+    mp::DefaultVMImageVault vault{hosts,
+                                  &blocking_downloader,
+                                  cache_dir.path(),
+                                  data_dir.path(),
+                                  mp::days{0}};
+
+    auto fetch_future = std::async(std::launch::async, [&] {
+        vault.fetch_image(mp::FetchType::ImageOnly,
+                          default_query,
+                          stub_prepare,
+                          stub_monitor,
+                          std::nullopt,
+                          instance_dir);
+    });
+
+    // Wait for download to start
+    blocking_downloader.started.get_future().wait();
+
+    QDir images_dir{MP_UTILS.make_dir(cache_dir.path(), "vault/images")};
+    const auto entries = images_dir.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot);
+    ASSERT_EQ(entries.size(), 1);
+    const auto image_dir_path = entries.first().absoluteFilePath();
+
+    vault.prune_expired_images();
+
+    EXPECT_TRUE(QFileInfo::exists(image_dir_path));
+
+    // Release the downloader
+    blocking_downloader.proceed.set_value();
+    fetch_future.wait();
+}
+
 TEST_F(ImageVault, invalidImageDirIsRemoved)
 {
     mp::DefaultVMImageVault vault{hosts,