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();