From df33fb33070762544bb057781c8c965f10e10ac5 Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Wed, 20 May 2026 13:48:57 -0700 Subject: [PATCH 1/9] Surface MSI reboot-required failures instead of silently dropping VHDs When the embedded MSI in the Microsoft Store MSIX cannot replace a locked file (most commonly system.vhd, which is mounted whenever a WSL2 instance is running), Windows Installer renames the existing file to C:\Windows\Installer\Config.Msi\.rbf, schedules the new file via MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT), and returns ERROR_SUCCESS_REBOOT_REQUIRED (3010). InstallMsipackageImpl in wslinstaller silently converted 3010 to ERROR_SUCCESS and returned success to its caller. The user was told nothing; their next wsl invocation hit a now-empty C:\Program Files\WSL install dir (system.vhd physically gone until reboot) and produced a confusing "vhd missing" failure - perceived as data loss. This change: * Stops swallowing 3010 in InstallMsipackageImpl. The MSI log is now preserved on 3010 (previously deleted) to aid diagnostics. * Sets a volatile registry marker (HKLM\Software\Microsoft\Windows\CurrentVersion\Lxss\MSI\RebootPending) using REG_OPTION_VOLATILE so the kernel auto-clears it on reboot. No cleanup path is needed; the marker is gone iff the user has rebooted. * Adds a marker check in LxssUserSession::_CreateInstance (service side) which throws a localized user-facing error (MessageUpdateRebootRequired) any time a client tries to launch a distro while the reboot is pending. This catches all distro-launching client paths through the single service entry point: wsl.exe (lifted and MSI-installed), wslg.exe, bash.exe, VS Code Remote-WSL, etc. * Also checks the marker on entry to CallMsiPackage and throws on 3010 in WaitForMsiInstall, so the wsl --update / lifted-client paths surface the same error. User-visible behavior: > wsl WSL was updated but a system restart is required to complete the installation. Please reboot your machine and try again. Error code: Wsl/Service/CreateInstance/0x80070bc2 The user reboots; the volatile key is destroyed by the kernel; Windows' pending file-rename queue swaps the staged file into place; WSL works again. Adds an integration test, InstallerTests::UpgradeWithLockedFileReportsRebootRequired, that exercises the full path: uninstalls the MSI, memory-maps a dummy file at the install path to make it unrenameable, runs the MSIX installer to drive the WslInstaller service, polls for the marker, then verifies wsl echo OK fails with the expected message before cleaning up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- localization/strings/en-US/Resources.resw | 4 + src/windows/common/install.cpp | 51 +++++++++ src/windows/common/install.h | 8 ++ src/windows/service/exe/LxssUserSession.cpp | 9 ++ src/windows/wslinstaller/exe/WslInstaller.cpp | 22 +++- test/windows/InstallerTests.cpp | 100 ++++++++++++++++++ 6 files changed, 189 insertions(+), 5 deletions(-) diff --git a/localization/strings/en-US/Resources.resw b/localization/strings/en-US/Resources.resw index e82e9d9aa..938553d8c 100644 --- a/localization/strings/en-US/Resources.resw +++ b/localization/strings/en-US/Resources.resw @@ -1004,6 +1004,10 @@ Falling back to NAT networking. WSL is finishing an upgrade... + + WSL was updated but a system restart is required to complete the installation. Please reboot your machine and try again. + {Locked="WSL"} + Update failed (exit code: {}). {FixedPlaceholder="{}"}Command line arguments, file names and string inserts should not be translated diff --git a/src/windows/common/install.cpp b/src/windows/common/install.cpp index a268f8bf6..117984c2c 100644 --- a/src/windows/common/install.cpp +++ b/src/windows/common/install.cpp @@ -166,6 +166,16 @@ void WaitForMsiInstall() wprintf(L"\n%ls\n", message.get()); } + if (exitCode == ERROR_SUCCESS_REBOOT_REQUIRED) + { + // The MSI completed but one or more files (typically system.vhd or wslservice.exe) + // were in use and have been scheduled for replacement on the next reboot. Surface + // this distinctly so the caller does not proceed to launch WSL against a + // half-installed package — notably, the previous system.vhd has been renamed away + // to %WINDIR%\Installer\Config.Msi\*.rbf and the new one is not yet in place. + THROW_HR_WITH_USER_ERROR(HRESULT_FROM_WIN32(ERROR_SUCCESS_REBOOT_REQUIRED), wsl::shared::Localization::MessageUpdateRebootRequired()); + } + if (exitCode != 0) { THROW_HR_WITH_USER_ERROR(HRESULT_FROM_WIN32(exitCode), wsl::shared::Localization::MessageUpdateFailed(exitCode)); @@ -231,10 +241,39 @@ void ConfigureMsiLogging(_In_opt_ LPCWSTR LogFile, _In_ const std::function LxssUserSessionImpl::_CreateInstance(_In_op { ExecutionContext context(Context::CreateInstance); + // If a previous MSI install is pending reboot (files like system.vhd have been + // renamed away and are waiting for delayed replacement), block instance creation + // with a clear error rather than launching against a broken install. + if (wsl::windows::common::install::IsRebootRequired()) + { + THROW_HR_WITH_USER_ERROR(HRESULT_FROM_WIN32(ERROR_SUCCESS_REBOOT_REQUIRED), wsl::shared::Localization::MessageUpdateRebootRequired()); + } + // Validate flags. THROW_HR_IF(E_INVALIDARG, (WI_IsAnyFlagSet(Flags, ~LXSS_CREATE_INSTANCE_FLAGS_ALL))); diff --git a/src/windows/wslinstaller/exe/WslInstaller.cpp b/src/windows/wslinstaller/exe/WslInstaller.cpp index 7ac796d6c..f50353c7c 100644 --- a/src/windows/wslinstaller/exe/WslInstaller.cpp +++ b/src/windows/wslinstaller/exe/WslInstaller.cpp @@ -97,13 +97,22 @@ std::pair InstallMsipackageImpl() auto result = wsl::windows::common::install::UpgradeViaMsi( GetMsiPackagePath().c_str(), L"SKIPMSIX=1", logFile.has_value() ? logFile->path.c_str() : nullptr, messageCallback); - // ERROR_SUCCESS_REBOOT_REQUIRED (3010) means the install succeeded but some files - // will be replaced on the next reboot. Treat as success since the service runs - // silently with no user-facing console. + // ERROR_SUCCESS_REBOOT_REQUIRED (3010) means MSI completed its database changes but + // one or more files (e.g. system.vhd, wslservice.exe) were in use and have been moved + // to .rbf backups under %WINDIR%\Installer\Config.Msi with their replacements scheduled + // via MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT). Until the user reboots, the install + // location is in a half-replaced state — notably, the old system.vhd has been renamed + // away and the new one is not yet in place. Propagate this distinctly so the client + // does not proceed to launch WSL against a broken install (which surfaces to users as + // "my system.vhd disappeared after the update"). const bool rebootRequired = (result == ERROR_SUCCESS_REBOOT_REQUIRED); + + // Write a volatile (auto-cleared on reboot) registry marker so subsequent wsl.exe + // invocations know the install is incomplete. Without this, CallMsiPackage() would + // short-circuit and launch against the half-replaced install directory. if (rebootRequired) { - result = ERROR_SUCCESS; + wsl::windows::common::install::SetRebootRequiredMarker(); } WSL_LOG( @@ -112,7 +121,10 @@ std::pair InstallMsipackageImpl() TraceLoggingValue(rebootRequired, "rebootRequired"), TraceLoggingValue(errors.c_str(), "errorMessage")); - if (result != ERROR_SUCCESS && result != ERROR_SUCCESS_REBOOT_REQUIRED) + // Preserve MSI logs on anything other than a clean success — including + // ERROR_SUCCESS_REBOOT_REQUIRED, since the log identifies which file(s) forced the + // delayed rename. + if (result != ERROR_SUCCESS) { clearLogs.release(); } diff --git a/test/windows/InstallerTests.cpp b/test/windows/InstallerTests.cpp index 91d9c699c..3d323bf2e 100644 --- a/test/windows/InstallerTests.cpp +++ b/test/windows/InstallerTests.cpp @@ -17,6 +17,7 @@ Module Name: #include "Common.h" #include "registry.hpp" +#include "install.h" #include "PluginTests.h" #include "wslcsdk.h" @@ -1125,4 +1126,103 @@ class InstallerTests SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, nullptr, nullptr); VerifyWslSettingsProtocolAssociationExistsWithRetry(); } + + TEST_METHOD(UpgradeWithLockedFileReportsRebootRequired) + { + // Ensure the MSI is installed cleanly first. If a prior test run was + // interrupted, the MSI may be missing — reinstall it. + if (!IsMsiPackageInstalled()) + { + InstallMsi(); + } + + VERIFY_IS_TRUE(IsMsiPackageInstalled()); + + // Stop the WSL service so nothing holds files open. + StopWslService(); + + // Uninstall the MSI. MsiInstallProduct on an already-registered ProductCode + // enters maintenance mode and won't replace files. We need a fresh install + // so the MSI actually writes files and hits the lock. + UninstallMsi(); + + // Create a dummy system.vhd in the install directory so we have something to lock. + // When the MSI does a fresh install it will try to write its real system.vhd here, + // but can't because the dummy is memory-mapped — resulting in 3010. + std::filesystem::create_directories(m_installedPath); + auto systemVhdPath = m_installedPath / L"system.vhd"; + { + wil::unique_hfile dummyHandle{ + CreateFileW(systemVhdPath.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr)}; + VERIFY_IS_TRUE(dummyHandle.is_valid()); + BYTE pad = 0; + DWORD written = 0; + VERIFY_WIN32_BOOL_SUCCEEDED(WriteFile(dummyHandle.get(), &pad, 1, &written, nullptr)); + } + + // Memory-map the dummy to simulate a running VM. A memory-mapped file cannot + // be renamed or deleted regardless of directory permissions — this forces the MSI + // to schedule a delayed rename (MoveFileEx MOVEFILE_DELAY_UNTIL_REBOOT) and return 3010. + wil::unique_hfile lockedHandle{CreateFileW( + systemVhdPath.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)}; + VERIFY_IS_TRUE(lockedHandle.is_valid()); + + wil::unique_handle mapping{CreateFileMappingW(lockedHandle.get(), nullptr, PAGE_READONLY, 0, 0, nullptr)}; + VERIFY_IS_TRUE(mapping.is_valid()); + + auto* mapView = MapViewOfFile(mapping.get(), FILE_MAP_READ, 0, 0, 1); + VERIFY_IS_NOT_NULL(mapView); + auto unmapOnExit = wil::scope_exit([mapView]() { UnmapViewOfFile(mapView); }); + + // Fake a stale version so the WslInstaller service thinks an upgrade is needed. + RegistryKeyChange version( + HKEY_LOCAL_MACHINE, L"Software\\Microsoft\\Windows\\CurrentVersion\\Lxss\\MSI", L"Version", L"1.0.0"); + + // Remove the MSIX so we can reinstall it to trigger the WslInstaller service. + UninstallMsix(); + VERIFY_IS_FALSE(IsMsixInstalled()); + + // Install the MSIX — this starts the WslInstaller service which detects the + // stale version and runs the MSI. With system.vhd locked, the MSI returns 3010 + // and WslInstaller calls SetRebootRequiredMarker(). + InstallMsix(); + + // Wait for the reboot-required marker — this is the signal that the installer + // completed the MSI install and hit the locked file (3010). + auto waitForMarker = []() { THROW_HR_IF(E_FAIL, !wsl::windows::common::install::IsRebootRequired()); }; + + try + { + wsl::shared::retry::RetryWithTimeout(waitForMarker, std::chrono::seconds(1), std::chrono::minutes(5)); + } + catch (...) + { + VERIFY_FAIL("Timed out waiting for reboot-required marker to be set by WslInstaller"); + } + + // Release the memory map and handle — the file has been renamed to .rbf by MSI. + unmapOnExit.reset(); + mapping.reset(); + lockedHandle.reset(); + + // Verify that launching wsl.exe (a command that goes through CallMsiPackage) fails + // with the reboot-required error. + auto wslCommandLine = LxssGenerateWslCommandLine(L"echo OK"); + auto [output, warnings, wslExitCode] = LxsstuLaunchCommandAndCaptureOutputWithResult(wslCommandLine.data()); + + LogInfo("wsl echo OK output: %ls", output.c_str()); + LogInfo("wsl echo OK warnings: %ls", warnings.c_str()); + VERIFY_ARE_NOT_EQUAL(wslExitCode, 0); + + // The error message should mention a restart is required. + auto combined = output + warnings; + VERIFY_IS_TRUE(combined.find(L"restart") != std::wstring::npos); + + // Clean up: delete the volatile marker and reinstall cleanly. + wsl::windows::common::registry::DeleteKey(OpenLxssMachineKey(KEY_ALL_ACCESS).get(), L"MSI\\RebootPending"); + VERIFY_IS_FALSE(wsl::windows::common::install::IsRebootRequired()); + + InstallMsi(); + ValidatePackageInstalledProperly(); + } }; From 02969ca96b49807d15d634775e31e24265d20922 Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Wed, 20 May 2026 14:32:16 -0700 Subject: [PATCH 2/9] Address PR feedback: don't block non-distro commands; auto-clear marker on success The original CallMsiPackage() early-throw blocked every MSIX-lifted wsl.exe invocation (including --version, --list, --shutdown, --update) regardless of whether it would touch the half-installed files. Remove the early throw and rely on the service-side _CreateInstance check, which already gates exactly the distro-launching paths that actually depend on the broken install dir. Also add ClearRebootRequiredMarker() and call it from any MSI install path that completes with ERROR_SUCCESS, so a 'wsl --shutdown; wsl --update' flow can self-recover without requiring an actual reboot. Extend the integration test to verify wsl --version still succeeds with the marker set, and that the marker is cleared after a clean reinstall. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/windows/common/install.cpp | 28 ++++++++++++++----- src/windows/common/install.h | 5 ++++ src/windows/wslinstaller/exe/WslInstaller.cpp | 6 ++++ test/windows/InstallerTests.cpp | 9 ++++++ 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/windows/common/install.cpp b/src/windows/common/install.cpp index 117984c2c..46e75271f 100644 --- a/src/windows/common/install.cpp +++ b/src/windows/common/install.cpp @@ -106,6 +106,7 @@ int UpdatePackageImpl(bool preRelease, bool repair) if (exitCode == ERROR_SUCCESS_REBOOT_REQUIRED) { + wsl::windows::common::install::SetRebootRequiredMarker(); PrintSystemError(ERROR_SUCCESS_REBOOT_REQUIRED); } else if (exitCode != 0) @@ -116,6 +117,11 @@ int UpdatePackageImpl(bool preRelease, bool repair) wsl::shared::Localization::MessageUpdateFailed(exitCode) + L"\r\n" + wsl::shared::Localization::MessageSeeLogFile(logFile.c_str())); } + else + { + // Clean install — clear any pending reboot marker from a prior 3010-result install. + wsl::windows::common::install::ClearRebootRequiredMarker(); + } } else { @@ -251,6 +257,15 @@ void wsl::windows::common::install::SetRebootRequiredMarker() WriteDword(key.get(), nullptr, L"RebootRequired", 1); } +void wsl::windows::common::install::ClearRebootRequiredMarker() +{ + // Best-effort. registry::DeleteKey treats ERROR_FILE_NOT_FOUND as a no-op, + // so this is safe to call on any successful install path even if no marker + // was previously set. + const auto lxssKey = OpenLxssMachineKey(KEY_ALL_ACCESS); + wsl::windows::common::registry::DeleteKey(lxssKey.get(), c_rebootPendingSubkey); +} + bool wsl::windows::common::install::IsRebootRequired() { auto [key, hr] = OpenKeyNoThrow(OpenLxssMachineKey(KEY_READ).get(), c_rebootPendingSubkey, KEY_READ); @@ -266,13 +281,12 @@ int wsl::windows::common::install::CallMsiPackage() { wsl::windows::common::ExecutionContext context(wsl::windows::common::CallMsi); - // If a previous MSI install returned ERROR_SUCCESS_REBOOT_REQUIRED, files like system.vhd - // are pending delayed-rename and the install directory is incomplete. Block early rather - // than launching against a broken install. - if (IsRebootRequired()) - { - THROW_HR_WITH_USER_ERROR(HRESULT_FROM_WIN32(ERROR_SUCCESS_REBOOT_REQUIRED), wsl::shared::Localization::MessageUpdateRebootRequired()); - } + // N.B. We intentionally do not block here on IsRebootRequired(). CallMsiPackage() + // is the bootstrap forwarder used by every MSIX-lifted wsl.exe invocation, + // including read-only commands like `--version`, `--list`, and recovery commands + // like `--shutdown` and `--update`. Blocking those would be user-hostile. + // The service-side check in LxssUserSession::_CreateInstance gates the + // distro-launching paths that actually depend on the half-installed files. auto msiPath = GetMsiPackagePath(); if (!msiPath.has_value()) diff --git a/src/windows/common/install.h b/src/windows/common/install.h index 0fe7669f6..aa15c206a 100644 --- a/src/windows/common/install.h +++ b/src/windows/common/install.h @@ -39,4 +39,9 @@ void SetRebootRequiredMarker(); // since a 3010-result MSI install). bool IsRebootRequired(); +// Clears the reboot-required marker. Should be called after any MSI install path that +// completes successfully without ERROR_SUCCESS_REBOOT_REQUIRED, so a user who shuts WSL +// down and runs `wsl --update` can self-recover without an additional reboot. +void ClearRebootRequiredMarker(); + } // namespace wsl::windows::common::install diff --git a/src/windows/wslinstaller/exe/WslInstaller.cpp b/src/windows/wslinstaller/exe/WslInstaller.cpp index f50353c7c..b279d0f76 100644 --- a/src/windows/wslinstaller/exe/WslInstaller.cpp +++ b/src/windows/wslinstaller/exe/WslInstaller.cpp @@ -114,6 +114,12 @@ std::pair InstallMsipackageImpl() { wsl::windows::common::install::SetRebootRequiredMarker(); } + else if (result == ERROR_SUCCESS) + { + // A clean install means any previously-pending reboot has been resolved (the new + // files are in place). Clear the marker so the user can resume without a reboot. + wsl::windows::common::install::ClearRebootRequiredMarker(); + } WSL_LOG( "MSIUpgradeResult", diff --git a/test/windows/InstallerTests.cpp b/test/windows/InstallerTests.cpp index 3d323bf2e..e3068e3a6 100644 --- a/test/windows/InstallerTests.cpp +++ b/test/windows/InstallerTests.cpp @@ -1218,6 +1218,15 @@ class InstallerTests auto combined = output + warnings; VERIFY_IS_TRUE(combined.find(L"restart") != std::wstring::npos); + // Non-distro commands (--version, --list, --shutdown, --update) must keep working + // even with the marker set — they go through CallMsiPackage but don't reach the + // service's _CreateInstance gate, so they should not be blocked. + std::wstring versionCmd = wsl::windows::common::wslutil::GetMsiPackagePath().value_or(L"") + L"\\wsl.exe --version"; + auto [versionOutput, versionWarnings, versionExitCode] = + LxsstuLaunchCommandAndCaptureOutputWithResult(versionCmd.data()); + LogInfo("wsl --version output: %ls", versionOutput.c_str()); + VERIFY_ARE_EQUAL(versionExitCode, 0L); + // Clean up: delete the volatile marker and reinstall cleanly. wsl::windows::common::registry::DeleteKey(OpenLxssMachineKey(KEY_ALL_ACCESS).get(), L"MSI\\RebootPending"); VERIFY_IS_FALSE(wsl::windows::common::install::IsRebootRequired()); From 4c2094d7567a65fe58f37bace4c2ff7b24ddbe1a Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Thu, 21 May 2026 11:58:40 -0700 Subject: [PATCH 3/9] install: emit warning instead of error when reboot is required after MSI update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the MSI installer returns ERROR_SUCCESS_REBOOT_REQUIRED (files in use, delayed rename pending), WaitForMsiInstall previously threw an error that blocked all subsequent wsl.exe invocations. This was unnecessarily harsh — the service's _CreateInstance gate already emits the same reboot-required warning when a distro launch is attempted. Change WaitForMsiInstall to emit a user warning and return normally instead of throwing. Remove the now-dead reboot-specific rethrow in CallMsiPackage. Verified by InstallerTests::UpgradeWithLockedFileReportsRebootRequired (25/25 passing). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/windows/common/install.cpp | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/windows/common/install.cpp b/src/windows/common/install.cpp index 46e75271f..f149bdc59 100644 --- a/src/windows/common/install.cpp +++ b/src/windows/common/install.cpp @@ -175,11 +175,11 @@ void WaitForMsiInstall() if (exitCode == ERROR_SUCCESS_REBOOT_REQUIRED) { // The MSI completed but one or more files (typically system.vhd or wslservice.exe) - // were in use and have been scheduled for replacement on the next reboot. Surface - // this distinctly so the caller does not proceed to launch WSL against a - // half-installed package — notably, the previous system.vhd has been renamed away - // to %WINDIR%\Installer\Config.Msi\*.rbf and the new one is not yet in place. - THROW_HR_WITH_USER_ERROR(HRESULT_FROM_WIN32(ERROR_SUCCESS_REBOOT_REQUIRED), wsl::shared::Localization::MessageUpdateRebootRequired()); + // were in use and have been scheduled for replacement on the next reboot. Warn + // the user so they understand why WSL may not work, but do not throw — the + // service's _CreateInstance gate will also warn when launching a distro. + EMIT_USER_WARNING(wsl::shared::Localization::MessageUpdateRebootRequired()); + return; } if (exitCode != 0) @@ -302,18 +302,6 @@ int wsl::windows::common::install::CallMsiPackage() { LOG_CAUGHT_EXCEPTION(); - // If the install completed but is pending a reboot to finish replacing files - // (ERROR_SUCCESS_REBOOT_REQUIRED), the registered MSI install path is already - // populated even though some files (e.g. system.vhd) are physically missing - // until the user reboots. Do not fall through to the race-recovery - // GetMsiPackagePath() retry below — that would silently proceed to launch - // wsl.exe against a half-installed package. Surface the reboot-required - // error to the user instead. - if (wil::ResultFromCaughtException() == HRESULT_FROM_WIN32(ERROR_SUCCESS_REBOOT_REQUIRED)) - { - throw; - } - // GetMsiPackagePath() will generate a user error if the registry access fails. // Save the error from GetMsiPackagePath() to return a proper 'install failed' message. auto savedError = context.ReportedError(); From c9d99858c67b1b9ac51a2f0c2b0946856e8ffbe3 Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Thu, 21 May 2026 12:50:52 -0700 Subject: [PATCH 4/9] test: remove FILE_SHARE_DELETE from locked file handle in reboot test Without FILE_SHARE_DELETE, MSI cannot open system.vhd with DELETE intent, making the 3010 (ERROR_SUCCESS_REBOOT_REQUIRED) outcome more reliable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/windows/InstallerTests.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test/windows/InstallerTests.cpp b/test/windows/InstallerTests.cpp index e3068e3a6..3044bd687 100644 --- a/test/windows/InstallerTests.cpp +++ b/test/windows/InstallerTests.cpp @@ -1164,7 +1164,7 @@ class InstallerTests // be renamed or deleted regardless of directory permissions — this forces the MSI // to schedule a delayed rename (MoveFileEx MOVEFILE_DELAY_UNTIL_REBOOT) and return 3010. wil::unique_hfile lockedHandle{CreateFileW( - systemVhdPath.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)}; + systemVhdPath.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)}; VERIFY_IS_TRUE(lockedHandle.is_valid()); wil::unique_handle mapping{CreateFileMappingW(lockedHandle.get(), nullptr, PAGE_READONLY, 0, 0, nullptr)}; @@ -1205,16 +1205,17 @@ class InstallerTests mapping.reset(); lockedHandle.reset(); - // Verify that launching wsl.exe (a command that goes through CallMsiPackage) fails - // with the reboot-required error. + // Verify that launching wsl.exe emits the reboot-required warning on stderr + // but does not fail with an error code attributable to the marker itself. + // (The underlying distro start may still fail because system.vhd is gone, + // but the user gets a clear heads-up about why.) auto wslCommandLine = LxssGenerateWslCommandLine(L"echo OK"); auto [output, warnings, wslExitCode] = LxsstuLaunchCommandAndCaptureOutputWithResult(wslCommandLine.data()); LogInfo("wsl echo OK output: %ls", output.c_str()); LogInfo("wsl echo OK warnings: %ls", warnings.c_str()); - VERIFY_ARE_NOT_EQUAL(wslExitCode, 0); - // The error message should mention a restart is required. + // The warning message should mention a restart is required. auto combined = output + warnings; VERIFY_IS_TRUE(combined.find(L"restart") != std::wstring::npos); From 9098aee907a82679ab19d2c8f303905a36ddbfb1 Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Thu, 21 May 2026 13:44:54 -0700 Subject: [PATCH 5/9] update to warning --- src/windows/service/exe/LxssUserSession.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/windows/service/exe/LxssUserSession.cpp b/src/windows/service/exe/LxssUserSession.cpp index a87707aab..402072cda 100644 --- a/src/windows/service/exe/LxssUserSession.cpp +++ b/src/windows/service/exe/LxssUserSession.cpp @@ -2492,11 +2492,11 @@ std::shared_ptr LxssUserSessionImpl::_CreateInstance(_In_op ExecutionContext context(Context::CreateInstance); // If a previous MSI install is pending reboot (files like system.vhd have been - // renamed away and are waiting for delayed replacement), block instance creation - // with a clear error rather than launching against a broken install. + // renamed away and are waiting for delayed replacement), warn the user + // but allow execution to continue. if (wsl::windows::common::install::IsRebootRequired()) { - THROW_HR_WITH_USER_ERROR(HRESULT_FROM_WIN32(ERROR_SUCCESS_REBOOT_REQUIRED), wsl::shared::Localization::MessageUpdateRebootRequired()); + EMIT_USER_WARNING(wsl::shared::Localization::MessageUpdateRebootRequired()); } // Validate flags. From bc7d36a066cd53c45e5248258ea11e51eb73914d Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Fri, 22 May 2026 06:30:08 -0700 Subject: [PATCH 6/9] Format InstallerTests.cpp Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/windows/InstallerTests.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/windows/InstallerTests.cpp b/test/windows/InstallerTests.cpp index 3044bd687..7747a8131 100644 --- a/test/windows/InstallerTests.cpp +++ b/test/windows/InstallerTests.cpp @@ -1223,8 +1223,7 @@ class InstallerTests // even with the marker set — they go through CallMsiPackage but don't reach the // service's _CreateInstance gate, so they should not be blocked. std::wstring versionCmd = wsl::windows::common::wslutil::GetMsiPackagePath().value_or(L"") + L"\\wsl.exe --version"; - auto [versionOutput, versionWarnings, versionExitCode] = - LxsstuLaunchCommandAndCaptureOutputWithResult(versionCmd.data()); + auto [versionOutput, versionWarnings, versionExitCode] = LxsstuLaunchCommandAndCaptureOutputWithResult(versionCmd.data()); LogInfo("wsl --version output: %ls", versionOutput.c_str()); VERIFY_ARE_EQUAL(versionExitCode, 0L); From 189d97ce580f8b5a39145cd75c6f8c336f59e98c Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Fri, 22 May 2026 06:37:11 -0700 Subject: [PATCH 7/9] test: use ValidateInstalledVersion in reboot test cleanup After the locked-file scenario, MoveFileEx DELAY_UNTIL_REBOOT entries are still pending so distro launches fail until an actual reboot. Using ValidateInstalledVersion (wsl --version) instead of ValidatePackageInstalledProperly (wsl echo OK) for the cleanup check avoids a spurious failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/windows/InstallerTests.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/windows/InstallerTests.cpp b/test/windows/InstallerTests.cpp index 7747a8131..13a2324c0 100644 --- a/test/windows/InstallerTests.cpp +++ b/test/windows/InstallerTests.cpp @@ -1232,6 +1232,8 @@ class InstallerTests VERIFY_IS_FALSE(wsl::windows::common::install::IsRebootRequired()); InstallMsi(); - ValidatePackageInstalledProperly(); + // Validate binaries only — distro launches may still fail because MoveFileEx + // DELAY_UNTIL_REBOOT entries from the locked-file scenario are still pending. + ValidateInstalledVersion(); } }; From 6dc890813a6ef080d2ba0bc922cfc459ef6a5d01 Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Fri, 22 May 2026 09:20:53 -0700 Subject: [PATCH 8/9] test: properly clean up PendingFileRenameOperations in test cleanup After the locked-file MSI scenario, MoveFileEx DELAY_UNTIL_REBOOT entries remain in PendingFileRenameOperations. Add ClearPendingFileRenameOperationsForPath() to strip WSL install path entries from that registry value before reinstalling, allowing ValidatePackageInstalledProperly() (wsl echo OK) to succeed. Also tightens the path match to require a separator boundary after the prefix so sibling directories (e.g. 'WSL2') are not inadvertently matched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/windows/InstallerTests.cpp | 92 +++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 4 deletions(-) diff --git a/test/windows/InstallerTests.cpp b/test/windows/InstallerTests.cpp index 13a2324c0..19dfeb3f5 100644 --- a/test/windows/InstallerTests.cpp +++ b/test/windows/InstallerTests.cpp @@ -340,6 +340,90 @@ class InstallerTests // TODO: check wslsupport, wslapi and p9rdr } + // Remove any PendingFileRenameOperations entries whose source path falls under installPath. + // The MSI uses MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) when a file is locked, leaving stale + // entries that prevent a subsequent MSI install from putting the system into a clean state. + void ClearPendingFileRenameOperationsForPath(const std::filesystem::path& installPath) const + { + static constexpr auto c_sessionManagerKey = L"SYSTEM\\CurrentControlSet\\Control\\Session Manager"; + static constexpr auto c_pendingRenameValue = L"PendingFileRenameOperations"; + + auto [key, hr] = wsl::windows::common::registry::OpenKeyNoThrow(HKEY_LOCAL_MACHINE, c_sessionManagerKey, KEY_READ | KEY_WRITE); + if (FAILED(hr)) + { + return; + } + + DWORD size = 0; + if (RegGetValueW(key.get(), nullptr, c_pendingRenameValue, RRF_RT_REG_MULTI_SZ, nullptr, nullptr, &size) != ERROR_SUCCESS || size == 0) + { + return; + } + + std::vector buffer(size / sizeof(WCHAR) + 2); + THROW_IF_WIN32_ERROR(RegGetValueW(key.get(), nullptr, c_pendingRenameValue, RRF_RT_REG_MULTI_SZ, nullptr, buffer.data(), &size)); + + // Entries are stored as null-separated pairs: \0\0\0 + // Sources may carry a \??\ NT-namespace prefix — strip it before comparing. + // Normalize: strip trailing separators so the boundary check is unambiguous. + auto installPathStr = installPath.wstring(); + while (!installPathStr.empty() && (installPathStr.back() == L'\\' || installPathStr.back() == L'/')) + { + installPathStr.pop_back(); + } + + auto matchesInstallPath = [&installPathStr](std::wstring_view src) { + if (wsl::shared::string::StartsWith(src, std::wstring_view{L"\\??\\"}, true)) + { + src = src.substr(4); + } + + if (!wsl::shared::string::StartsWith(src, installPathStr, true)) + { + return false; + } + + // Require a path separator (or exact match) after the prefix so that + // e.g. "C:\Program Files\WSL2" is not matched by "C:\Program Files\WSL". + return src.size() == installPathStr.size() || src[installPathStr.size()] == L'\\' || src[installPathStr.size()] == L'/'; + }; + + std::wstring filtered; + for (auto* p = buffer.data(); *p != UNICODE_NULL;) + { + std::wstring src{p}; + p += src.size() + 1; + std::wstring dst{p}; + p += dst.size() + 1; + + if (!matchesInstallPath(src)) + { + filtered += src; + filtered += UNICODE_NULL; + filtered += dst; + filtered += UNICODE_NULL; + } + } + filtered += UNICODE_NULL; // REG_MULTI_SZ double-null terminator + + if (filtered.size() == 1) + { + // All matching entries removed; delete the value entirely. + auto error = RegDeleteValueW(key.get(), c_pendingRenameValue); + THROW_WIN32_IF(error, error != ERROR_SUCCESS && error != ERROR_FILE_NOT_FOUND); + } + else + { + THROW_IF_WIN32_ERROR(RegSetValueExW( + key.get(), + c_pendingRenameValue, + 0, + REG_MULTI_SZ, + reinterpret_cast(filtered.c_str()), + static_cast(filtered.size() * sizeof(WCHAR)))); + } + } + void DeleteProductCode() const { const auto msiKey = wsl::windows::common::registry::OpenKey(m_lxssKey.get(), L"MSI", KEY_ALL_ACCESS); @@ -1227,13 +1311,13 @@ class InstallerTests LogInfo("wsl --version output: %ls", versionOutput.c_str()); VERIFY_ARE_EQUAL(versionExitCode, 0L); - // Clean up: delete the volatile marker and reinstall cleanly. + // Clean up: clear any pending file-rename entries left by the locked-file MSI run, + // delete the volatile marker, then reinstall so subsequent tests start from a clean state. + ClearPendingFileRenameOperationsForPath(m_installedPath); wsl::windows::common::registry::DeleteKey(OpenLxssMachineKey(KEY_ALL_ACCESS).get(), L"MSI\\RebootPending"); VERIFY_IS_FALSE(wsl::windows::common::install::IsRebootRequired()); InstallMsi(); - // Validate binaries only — distro launches may still fail because MoveFileEx - // DELAY_UNTIL_REBOOT entries from the locked-file scenario are still pending. - ValidateInstalledVersion(); + ValidatePackageInstalledProperly(); } }; From aed1191a930ec32962477cb93a2c067b8f283cf5 Mon Sep 17 00:00:00 2001 From: Ben Hillis Date: Fri, 22 May 2026 10:48:18 -0700 Subject: [PATCH 9/9] review: use minimal registry access and localized string in test assertion - SetRebootRequiredMarker: KEY_ALL_ACCESS -> KEY_CREATE_SUB_KEY - ClearRebootRequiredMarker: KEY_ALL_ACCESS -> KEY_WRITE - Test assertion: replace brittle L"restart" substring check with Localization::MessageUpdateRebootRequired() for locale robustness Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/windows/common/install.cpp | 4 ++-- test/windows/InstallerTests.cpp | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/windows/common/install.cpp b/src/windows/common/install.cpp index f149bdc59..8508f7724 100644 --- a/src/windows/common/install.cpp +++ b/src/windows/common/install.cpp @@ -251,7 +251,7 @@ static constexpr auto c_rebootPendingSubkey = L"MSI\\RebootPending"; void wsl::windows::common::install::SetRebootRequiredMarker() { - const auto lxssKey = OpenLxssMachineKey(KEY_ALL_ACCESS); + const auto lxssKey = OpenLxssMachineKey(KEY_CREATE_SUB_KEY); // REG_OPTION_VOLATILE: key is automatically deleted on reboot. auto key = CreateKey(lxssKey.get(), c_rebootPendingSubkey, KEY_SET_VALUE, nullptr, REG_OPTION_VOLATILE); WriteDword(key.get(), nullptr, L"RebootRequired", 1); @@ -262,7 +262,7 @@ void wsl::windows::common::install::ClearRebootRequiredMarker() // Best-effort. registry::DeleteKey treats ERROR_FILE_NOT_FOUND as a no-op, // so this is safe to call on any successful install path even if no marker // was previously set. - const auto lxssKey = OpenLxssMachineKey(KEY_ALL_ACCESS); + const auto lxssKey = OpenLxssMachineKey(KEY_WRITE); wsl::windows::common::registry::DeleteKey(lxssKey.get(), c_rebootPendingSubkey); } diff --git a/test/windows/InstallerTests.cpp b/test/windows/InstallerTests.cpp index 19dfeb3f5..03260d8d6 100644 --- a/test/windows/InstallerTests.cpp +++ b/test/windows/InstallerTests.cpp @@ -1299,9 +1299,8 @@ class InstallerTests LogInfo("wsl echo OK output: %ls", output.c_str()); LogInfo("wsl echo OK warnings: %ls", warnings.c_str()); - // The warning message should mention a restart is required. - auto combined = output + warnings; - VERIFY_IS_TRUE(combined.find(L"restart") != std::wstring::npos); + // The reboot-required warning should appear on stderr. + VERIFY_IS_TRUE(warnings.find(wsl::shared::Localization::MessageUpdateRebootRequired()) != std::wstring::npos); // Non-distro commands (--version, --list, --shutdown, --update) must keep working // even with the marker set — they go through CallMsiPackage but don't reach the