Enable discard for WSLc volumes (#41411)
JohnMcPMS committed
Aug 21, 2026 at 15:29 UTC
4cd2389160cc6db0038824cef9cd81cb433df336
3 files changed
+210
-3
src/windows/wslcsession/WSLCSession.cpp
+1
-1
@@ -641,7 +641,7 @@ void WSLCSession::ConfigureStorage(const WSLCSessionInitSettings& Settings, PSID
641
}
642
643
// Mount the device to /root.
644
- m_runtime.Vm().Mount(diskDevice.c_str(), c_containerdStorage, "ext4", "", 0);
644
+ m_runtime.Vm().Mount(diskDevice.c_str(), c_containerdStorage, "ext4", "discard", 0);
645
m_runtime.SetStorageMounted(true);
646
647
// Configure swap on a separate ephemeral VHD.
src/windows/wslcsession/WSLCVhdVolume.cpp
+5
-2
@@ -174,7 +174,9 @@ std::unique_ptr<WSLCVhdVolumeImpl> WSLCVhdVolumeImpl::Create(
174
VirtualMachine.Ext4Format(device, opts.Uid, opts.Gid);
175
176
auto virtualMachinePath = std::format("/mnt/wslc-volumes/{}", name);
177
- VirtualMachine.Mount(device.c_str(), virtualMachinePath.c_str(), "ext4", "", 0);
177
+
178
+ // These should match the mount options used in Open
179
+ VirtualMachine.Mount(device.c_str(), virtualMachinePath.c_str(), "ext4", "discard", 0);
180
181
auto mountCleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { VirtualMachine.Unmount(virtualMachinePath.c_str()); });
182
@@ -278,7 +280,8 @@ std::unique_ptr<WSLCVhdVolumeImpl> WSLCVhdVolumeImpl::Open(
280
auto [attachedLun, device] = VirtualMachine.AttachDisk(hostPath.c_str(), false);
281
auto attachCleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { VirtualMachine.DetachDisk(attachedLun); });
282
281
- VirtualMachine.Mount(device.c_str(), virtualMachinePath.c_str(), "ext4", "", 0);
283
+ // These should match the mount options used in Create
284
+ VirtualMachine.Mount(device.c_str(), virtualMachinePath.c_str(), "ext4", "discard", 0);
285
auto mountCleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { VirtualMachine.Unmount(virtualMachinePath.c_str()); });
286
287
RemoveLostFoundDirectory(VirtualMachine, Volume.Name, virtualMachinePath);
test/windows/WSLCTests.cpp
+204
@@ -1417,6 +1417,210 @@ class WSLCTests
1417
VERIFY_ARE_EQUAL(EnumReferenceFormatDigest, loaded[0].second);
1418
}
1419
1420
+ // Loading the same image tar repeatedly must not permanently grow the session storage VHD.
1421
+ //
1422
+ // Docker's /images/load handler extracts the incoming tar into a temporary directory under its data
1423
+ // root (/var/lib/docker, which is the storage VHD) before inspecting any digest, so every call
1424
+ // writes roughly one tar's worth of data to the VHD even when the layers already exist and are
1425
+ // deduplicated. That temporary directory is deleted afterwards, but the VHD is a non-sparse
1426
+ // dynamically expanding VHDX mounted without 'discard', so the freed blocks are never returned to
1427
+ // the host, and ext4 tends to satisfy the next extraction from a different region rather than
1428
+ // reusing the just-freed one. The result is a VHD that grows by about the tar size on every load.
1429
+ //
1430
+ // The size of the tar matters: a small tar is re-extracted into blocks the VHDX has already
1431
+ // allocated, so the growth plateaus immediately and the bug does not reproduce. This test therefore
1432
+ // builds a large image with incompressible layers (which is what a real from-source application
1433
+ // image looks like once 'save' has written its uncompressed layers out) rather than reusing one of
1434
+ // the small prebuilt test tars.
1435
+ WSLC_TEST_METHOD(LoadImageRepeatedDoesNotGrowStorageVhd)
1436
+ {
1437
+ SKIP_TEST_SERVER();
1438
+
1439
+ constexpr auto c_sessionName = L"wslc-load-image-vhd-growth";
1440
+ constexpr auto c_imageName = "wslc-test-load-growth:latest";
1441
+ constexpr auto c_layerCount = 4;
1442
+ constexpr auto c_layerSizeMb = 256;
1443
+ constexpr auto c_extraLoads = 3;
1444
+
1445
+ // Build the image in the shared session (it already has debian:latest), then export it. Each
1446
+ // layer is /dev/urandom so it cannot be compressed away, making the exported tar's size
1447
+ // representative of the data the engine has to move on every load.
1448
+ //
1449
+ // Pass /p:LoadGrowthTar=<path> to run the loop against an existing tar (for example one produced
1450
+ // by 'wslc build' + 'wslc save' for a real application image) instead of building one here.
1451
+ WEX::Common::String existingTar;
1452
+ WEX::TestExecution::RuntimeParameters::TryGetValue(L"LoadGrowthTar", existingTar);
1453
+ const bool useExistingTar = !existingTar.IsEmpty();
1454
+
1455
+ auto contextDir = std::filesystem::current_path() / "build-context-load-growth";
1456
+ const auto imageTar = useExistingTar ? std::filesystem::path{static_cast<LPCWSTR>(existingTar)}
1457
+ : std::filesystem::current_path() / "wslc-load-growth.tar";
1458
+
1459
+ auto buildCleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
1460
+ if (useExistingTar)
1461
+ {
1462
+ return;
1463
+ }
1464
+
1465
+ LOG_IF_FAILED(DeleteImageNoThrow(c_imageName, WSLCDeleteImageFlagsForce).first);
1466
+
1467
+ std::error_code ec;
1468
+ std::filesystem::remove_all(contextDir, ec);
1469
+ std::filesystem::remove(imageTar, ec);
1470
+ });
1471
+
1472
+ if (!useExistingTar)
1473
+ {
1474
+ std::filesystem::create_directories(contextDir);
1475
+
1476
+ {
1477
+ std::ofstream dockerfile(contextDir / "Dockerfile");
1478
+ dockerfile << "FROM debian:latest\n";
1479
+ for (auto i = 0; i < c_layerCount; i++)
1480
+ {
1481
+ dockerfile << std::format("RUN dd if=/dev/urandom bs=1M count={} of=/blob{}.bin status=none\n", c_layerSizeMb, i);
1482
+ }
1483
+ }
1484
+
1485
+ VERIFY_SUCCEEDED(BuildImageFromContext(contextDir, c_imageName));
1486
+
1487
+ wil::unique_handle tarFile{CreateFileW(
1488
+ imageTar.c_str(), GENERIC_WRITE | GENERIC_READ, FILE_SHARE_READ, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)};
1489
+ VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == tarFile.get());
1490
+ VERIFY_SUCCEEDED(m_defaultSession->SaveImage(ToCOMInputHandle(tarFile.get()), c_imageName, nullptr, nullptr));
1491
+ }
1492
+
1493
+ const auto tarSize = static_cast<uint64_t>(std::filesystem::file_size(imageTar));
1494
+ VERIFY_IS_TRUE(tarSize > 0);
1495
+
1496
+ // A dedicated storage directory is required: the shared class storage is preloaded with the test
1497
+ // images and is written to by every other test in this class, so its size says nothing here.
1498
+ const auto storageDir = std::filesystem::current_path() / "test-storage-load-image-growth";
1499
+ std::error_code storageError;
1500
+ std::filesystem::remove_all(storageDir, storageError);
1501
+ std::filesystem::create_directories(storageDir);
1502
+ auto storageCleanup = wil::scope_exit([&]() {
1503
+ std::error_code ec;
1504
+ std::filesystem::remove_all(storageDir, ec);
1505
+ });
1506
+
1507
+ auto settings = GetDefaultSessionSettings(c_sessionName);
1508
+ settings.StoragePath = storageDir.c_str();
1509
+ auto session = CreateSession(settings);
1510
+
1511
+ const auto vhdPath = storageDir / wsl::windows::wslc::DefaultStorageVhdName;
1512
+
1513
+ // Size on disk, which is what grows as the dynamically expanding VHDX allocates blocks.
1514
+ auto vhdSizeOnDisk = [&]() {
1515
+ DWORD highPart{};
1516
+ SetLastError(NO_ERROR);
1517
+ const auto lowPart = GetCompressedFileSizeW(vhdPath.c_str(), &highPart);
1518
+ THROW_LAST_ERROR_IF(lowPart == INVALID_FILE_SIZE && GetLastError() != NO_ERROR);
1519
+
1520
+ ULARGE_INTEGER size{};
1521
+ size.LowPart = lowPart;
1522
+ size.HighPart = highPart;
1523
+ return static_cast<uint64_t>(size.QuadPart);
1524
+ };
1525
+
1526
+ // Bytes used by the guest filesystem backing the docker data root.
1527
+ auto guestUsedBytes = [&]() {
1528
+ const auto result =
1529
+ ExpectCommandResult(session.get(), {"/bin/sh", "-c", "df -k /var/lib/docker | awk 'NR == 2 {print $3}'"}, 0);
1530
+
1531
+ return std::stoull(result.Output.at(1)) * 1024;
1532
+ };
1533
+
1534
+ // Entries left behind in the directory docker extracts the incoming tar into.
1535
+ auto guestTempEntryCount = [&]() {
1536
+ const auto result =
1537
+ ExpectCommandResult(session.get(), {"/bin/sh", "-c", "ls -A /var/lib/docker/tmp 2>/dev/null | wc -l"}, 0);
1538
+
1539
+ return std::stoull(result.Output.at(1));
1540
+ };
1541
+
1542
+ auto loadImage = [&]() {
1543
+ wil::unique_handle tarFile{
1544
+ CreateFileW(imageTar.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)};
1545
+ VERIFY_IS_FALSE(INVALID_HANDLE_VALUE == tarFile.get());
1546
+
1547
+ LARGE_INTEGER fileSize{};
1548
+ VERIFY_IS_TRUE(GetFileSizeEx(tarFile.get(), &fileSize));
1549
+ VERIFY_SUCCEEDED(session->LoadImage(ToCOMInputHandle(tarFile.get()), fileSize.QuadPart, nullptr, nullptr));
1550
+
1551
+ // Flush the guest page cache so the writes have reached the VHD before it is measured.
1552
+ ExpectCommandResult(session.get(), {"/bin/sh", "-c", "sync"}, 0);
1553
+ };
1554
+
1555
+ // The first load legitimately grows the VHD: this is where the layers are actually registered.
1556
+ // Everything measured after it is overhead from re-loading content docker already has.
1557
+ loadImage();
1558
+ if (!useExistingTar)
1559
+ {
1560
+ ExpectImagePresent(*session, c_imageName);
1561
+ }
1562
+
1563
+ const auto baselineVhdSize = vhdSizeOnDisk();
1564
+ const auto baselineGuestUsed = guestUsedBytes();
1565
+
1566
+ LogInfo(
1567
+ "Tar size=%llu, baseline vhd size on disk=%llu, baseline guest used=%llu",
1568
+ static_cast<unsigned long long>(tarSize),
1569
+ static_cast<unsigned long long>(baselineVhdSize),
1570
+ static_cast<unsigned long long>(baselineGuestUsed));
1571
+
1572
+ for (auto i = 0; i < c_extraLoads; i++)
1573
+ {
1574
+ loadImage();
1575
+
1576
+ const auto vhdSize = vhdSizeOnDisk();
1577
+ const auto guestUsed = guestUsedBytes();
1578
+
1579
+ LogInfo(
1580
+ "Load %d: vhd size on disk=%llu (+%lld), guest used=%llu (+%lld), temp entries=%llu",
1581
+ i + 1,
1582
+ static_cast<unsigned long long>(vhdSize),
1583
+ static_cast<long long>(vhdSize - baselineVhdSize),
1584
+ static_cast<unsigned long long>(guestUsed),
1585
+ static_cast<long long>(guestUsed - baselineGuestUsed),
1586
+ static_cast<unsigned long long>(guestTempEntryCount()));
1587
+ }
1588
+
1589
+ const auto finalVhdSize = vhdSizeOnDisk();
1590
+ const auto finalGuestUsed = guestUsedBytes();
1591
+
1592
+ // The host-side VHD must not grow by roughly one tar per load. The budget allows a single tar of
1593
+ // slack in total (with a floor so that small tars don't make this flaky), which is well under the
1594
+ // c_extraLoads * tarSize that linear growth would produce.
1595
+ constexpr uint64_t c_minimumGrowthBudget = 64ull * 1024 * 1024;
1596
+ const auto growthBudget = std::max<uint64_t>(tarSize, c_minimumGrowthBudget);
1597
+
1598
+ LogInfo(
1599
+ "Storage VHD grew by %llu bytes over %d reloads of a %llu byte tar (budget=%llu). Guest usage grew by %lld bytes.",
1600
+ static_cast<unsigned long long>(finalVhdSize - baselineVhdSize),
1601
+ c_extraLoads,
1602
+ static_cast<unsigned long long>(tarSize),
1603
+ static_cast<unsigned long long>(growthBudget),
1604
+ static_cast<long long>(finalGuestUsed - baselineGuestUsed));
1605
+
1606
+ // Reloading the same tar must not leave docker's extraction directory behind.
1607
+ VERIFY_ARE_EQUAL(0ull, guestTempEntryCount());
1608
+
1609
+ // Docker deduplicates the identical layers, so the guest filesystem must not retain a tar's
1610
+ // worth of data per load. A failure here means the temporary extraction is being leaked inside
1611
+ // the guest rather than merely being unreclaimable on the host.
1612
+ VERIFY_IS_TRUE(finalGuestUsed < baselineGuestUsed + tarSize);
1613
+
1614
+ if (finalVhdSize >= baselineVhdSize + growthBudget)
1615
+ {
1616
+ LogError("The storage VHD is growing with each load: the space is being written and then not reclaimed.");
1617
+
1618
+ VERIFY_FAIL();
1619
+ }
1620
+
1621
+ VERIFY_SUCCEEDED(session->Terminate());
1622
+ }
1623
+
1624
WSLC_TEST_METHOD(ImportImage)
1625
{
1626
SKIP_TEST_SERVER();