Serialize move/resize/set-sparse against in-progress conversions (#41170)

* Serialize move/resize/set-sparse against in-progress conversions MoveDistribution, SetSparse, and ResizeDistribution mutate a distribution's VHD but never checked m_lockedDistributions. A conversion (--set-version) or export releases m_instanceLock while running yet keeps the distribution in m_lockedDistributions, so these operations could start mid-conversion and race on the same VHD (e.g. a corrupt export, or the VHD moved/resized out from under a conversion). Add _EnsureNotLocked under m_instanceLock to each so they fail with E_ILLEGAL_STATE_CHANGE while a conversion/export/compaction is in progress, matching how the conversion paths already guard each other via _ConversionBegin. Add a regression test that holds a distribution in the Exporting state (via a blocked export pipe) and verifies --resize, --set-sparse, and --move are all rejected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 37ad1fd7-a452-4056-a8cc-e91cf7870a60 * Generalize lock-guard comments to conversion/export Compaction isn't part of this branch, so drop it from the comments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 37ad1fd7-a452-4056-a8cc-e91cf7870a60 * Address review: deterministic pipe block and accurate cleanup comment - Use an explicit 1-byte pipe buffer so the export blocks immediately regardless of test distro size, instead of relying on the default buffer filling. - Fix the cleanup comment: closing the read end unblocks the export via a broken pipe, it does not drain it to completion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 37ad1fd7-a452-4056-a8cc-e91cf7870a60 --------- Co-authored-by: Ben Hillis <benhill@ntdev.microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 37ad1fd7-a452-4056-a8cc-e91cf7870a60

Ben Hillis committed Jul 27, 2026 at 13:24 UTC 178520010b3e88a89fe526727cea96599f78112c
2 files changed +68
src/windows/service/exe/LxssUserSession.cpp
+14
@@ -914,6 +914,11 @@ HRESULT LxssUserSessionImpl::MoveDistribution(_In_ LPCGUID DistroGuid, _In_ LPCW
914 // Fail if the distribution is running.
915 RETURN_HR_IF(WSL_E_DISTRO_NOT_STOPPED, m_runningInstances.contains(*DistroGuid));
916
917 + // Fail if a conversion or export is in progress for this distribution. Those operations release
918 + // m_instanceLock while running but keep the distribution in m_lockedDistributions, so mutating
919 + // the VHD here would race with them.
920 + _EnsureNotLocked(DistroGuid);
921 +
922 // Lookup the distribution configuration
923 const auto lxssKey = s_OpenLxssUserKey();
924 _ValidateDistributionNameAndPathNotInUse(lxssKey.get(), Location, nullptr);
@@ -1774,6 +1779,10 @@ try
1779 // Don't attempt if running
1780 RETURN_HR_IF(WSL_E_DISTRO_NOT_STOPPED, m_runningInstances.contains(*DistroGuid));
1781
1782 + // Don't attempt while a conversion or export holds this distribution; those operations release
1783 + // m_instanceLock while running but keep the entry in m_lockedDistributions.
1784 + _EnsureNotLocked(DistroGuid);
1785 +
1786 const wil::unique_hfile vhd{::CreateFileW(configuration.VhdFilePath.c_str(), GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr)};
1787 if (!vhd)
1788 {
@@ -1803,6 +1812,11 @@ try
1812 const auto configuration = s_GetDistributionConfiguration(registration);
1813 RETURN_HR_IF(WSL_E_WSL2_NEEDED, WI_IsFlagClear(configuration.Flags, LXSS_DISTRO_FLAGS_VM_MODE));
1814
1815 + // Fail if a conversion or export is in progress; those operations release m_instanceLock while
1816 + // running but keep this distribution in m_lockedDistributions, so resizing its VHD now would
1817 + // race with them.
1818 + _EnsureNotLocked(DistroGuid);
1819 +
1820 const auto& vhdPath = configuration.VhdFilePath;
1821 if (m_utilityVm && m_utilityVm->IsVhdAttached(vhdPath.c_str()))
1822 {
test/windows/UnitTests.cpp
+54
@@ -3269,6 +3269,60 @@ Error code: Wsl/InstallDistro/WSL_E_DISTRO_NOT_FOUND
3269 }
3270 }
3271
3272 + // Verifies that VHD-mutating manage operations (--resize, --set-sparse, --move) are rejected while a
3273 + // long-running conversion/export holds the distribution lock, rather than racing with it on the VHD.
3274 + WSL2_TEST_METHOD(ManageRejectedWhileLocked)
3275 + {
3276 + constexpr auto name = L"manage-locked-test-distro";
3277 +
3278 + VERIFY_ARE_EQUAL(LxsstuLaunchWsl(std::format(L"--import {} . \"{}\" --version 2", name, g_testDistroPath)), 0L);
3279 + auto cleanupName =
3280 + wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [name]() { LxsstuLaunchWsl(std::format(L"--unregister {}", name)); });
3281 + WslShutdown();
3282 +
3283 + // Start an export to a pipe we deliberately don't drain. Use a tiny buffer so the export blocks
3284 + // as soon as it writes any data, regardless of the test distro's size, deterministically holding
3285 + // the distribution in the "Exporting" locked state.
3286 + auto [readPipe, writePipe] = CreateSubprocessPipe(false, true, 1);
3287 +
3288 + std::thread exportThread([&]() { LxsstuLaunchWsl(std::format(L"--export {} -", name), nullptr, writePipe.get()); });
3289 +
3290 + auto joinExport = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() {
3291 + // Close the read end so the blocked export fails with a broken pipe and returns, then join.
3292 + readPipe.reset();
3293 + if (exportThread.joinable())
3294 + {
3295 + exportThread.join();
3296 + }
3297 + });
3298 +
3299 + // Wait until the service reports the distribution as Exporting (i.e. the lock is held).
3300 + bool locked = false;
3301 + for (int i = 0; i < 100 && !locked; ++i)
3302 + {
3303 + auto [out, _] = LxsstuLaunchWslAndCaptureOutput(L"--list --verbose");
3304 + locked = (out.find(name) != std::wstring::npos) && (out.find(L"Exporting") != std::wstring::npos);
3305 + if (!locked)
3306 + {
3307 + std::this_thread::sleep_for(std::chrono::milliseconds(100));
3308 + }
3309 + }
3310 +
3311 + VERIFY_IS_TRUE(locked);
3312 +
3313 + // Each VHD-mutating manage operation must be rejected with E_ILLEGAL_STATE_CHANGE while the lock is held.
3314 + auto verifyRejected = [&](const std::wstring& command) {
3315 + auto [out, _] = LxsstuLaunchWslAndCaptureOutput(command, -1);
3316 + VERIFY_IS_TRUE(out.find(L"E_ILLEGAL_STATE_CHANGE") != std::wstring::npos);
3317 + };
3318 +
3319 + verifyRejected(std::format(L"--manage {} --resize 2GB", name));
3320 + verifyRejected(std::format(L"--manage {} --set-sparse false", name));
3321 +
3322 + const auto moveTarget = std::filesystem::absolute(L"manage-locked-move-target").wstring();
3323 + verifyRejected(std::format(L"--manage {} --move \"{}\"", name, moveTarget));
3324 + }
3325 +
3326 WSL2_TEST_METHOD(FileOffsets)
3327 {
3328 auto cleanup = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, []() { DeleteFile(L"output.txt"); });