From 8c2f67e13e39d1bd0fe49934412de9076cfbc6c9 Mon Sep 17 00:00:00 2001 From: KKRainbow <5665404+KKRainbow@users.noreply.github.com> Date: Sat, 17 Oct 2026 02:15:57 +0800 Subject: [PATCH] fix(windows): stop running GUI and services during installer updates (#2717) * fix(gui): stop Windows processes before installing updates Keep the NSIS installer in the current user context. Detect the target GUI and service before updating, and elevate a temporary helper only when stopping them requires administrator access. Stop services through SCM and wait for their processes to exit before closing the GUI. Retain the helper until installation completes so it can restore a previously running service without another UAC prompt. Restore the service on cancellation when its executable still exists. Prepare before Tauri launches an old uninstaller, and cover passive, silent, and direct uninstall paths. Add Windows service lifecycle tests with isolated executable fixtures. * fix(windows): report stopped before normal service exit Report SERVICE_STOPPED with no accepted controls before exiting after an explicit stop or successful completion. Reporting Running at exit makes SCM treat a normal stop as a failure and schedule a restart. This fixes the service lifecycle independently of installer changes. * fix(gui): prevent old services restarting during Windows updates Temporarily disable service startup before requesting a stop, then restore the original startup mode when the update finishes or exits. Released services can exit without reporting Stopped, causing SCM to queue a recovery restart even after an explicit stop request. Extend the Windows fixture to reproduce that exit behavior with failure recovery enabled and check that startup settings are restored. Record the minor directory-change and GUI-relaunch limitation from review. * docs(windows): record installer short-path matching limitation Record the 8.3 path alias mismatch found while testing the installer helper without administrator privileges. Document how to run the fixture with a full temporary-directory path and retain the issue as a minor follow-up without expanding the installer implementation. --- .../src-tauri/tauri.windows.conf.json | 5 +- easytier-gui/src-tauri/windows/README.md | 28 ++ .../src-tauri/windows/installer-hooks.nsh | 95 +++++++ .../src-tauri/windows/installer-processes.ps1 | 264 ++++++++++++++++++ .../windows/test-installer-processes.ps1 | 169 +++++++++++ easytier/src/core.rs | 4 +- 6 files changed, 562 insertions(+), 3 deletions(-) create mode 100644 easytier-gui/src-tauri/windows/README.md create mode 100644 easytier-gui/src-tauri/windows/installer-hooks.nsh create mode 100644 easytier-gui/src-tauri/windows/installer-processes.ps1 create mode 100644 easytier-gui/src-tauri/windows/test-installer-processes.ps1 diff --git a/easytier-gui/src-tauri/tauri.windows.conf.json b/easytier-gui/src-tauri/tauri.windows.conf.json index b71ceaa6..56c2cb38 100644 --- a/easytier-gui/src-tauri/tauri.windows.conf.json +++ b/easytier-gui/src-tauri/tauri.windows.conf.json @@ -7,9 +7,12 @@ "./*.sys" ], "windows": { + "nsis": { + "installerHooks": "./windows/installer-hooks.nsh" + }, "webviewInstallMode": { "type": "embedBootstrapper" } } } -} \ No newline at end of file +} diff --git a/easytier-gui/src-tauri/windows/README.md b/easytier-gui/src-tauri/windows/README.md new file mode 100644 index 00000000..1f500eca --- /dev/null +++ b/easytier-gui/src-tauri/windows/README.md @@ -0,0 +1,28 @@ +# Windows installer process handling + +NSIS remains a current-user installer. Its helper requests elevation only +when a running service or GUI requires it, and restores service startup and +running state after installation. During an update it temporarily disables +service startup because released EasyTier versions can trigger SCM recovery +even after an explicit stop. + +Run `test-installer-processes.ps1` with Windows PowerShell as administrator +on a test machine without an existing `easytier-gui` service. The test uses +isolated executables and a service that reproduces the old exit behavior +with failure recovery enabled. Run it with `-GuiOnly` from an unelevated +shell to check the path that needs no elevation. + +## Known limitations + +If preparation on the welcome page has already started a helper, it is +reused at installation time. Changing to another directory containing a +running GUI, or relaunching the GUI between those pages, may still cause +Tauri's default process check to fail. Rechecking those processes is a +separate follow-up; it was recorded as a minor finding in the final review. + +Executable matching does not expand Windows 8.3 short-path aliases. An +installation directory expressed using a short alias may not match the +process image's full path. This was reproduced with LocalService's short +temporary-directory path; the unprivileged GUI test passes when its +temporary directory uses the full path. This is recorded as a minor +follow-up. Use a full temporary-directory path when running the fixture. diff --git a/easytier-gui/src-tauri/windows/installer-hooks.nsh b/easytier-gui/src-tauri/windows/installer-hooks.nsh new file mode 100644 index 00000000..ca403dab --- /dev/null +++ b/easytier-gui/src-tauri/windows/installer-hooks.nsh @@ -0,0 +1,95 @@ +!define EASYTIER_INSTALLER_SCRIPT "${__FILEDIR__}\installer-processes.ps1" + +; Tauri uninstalls the old version on its reinstall page, before PREINSTALL. +; MUI consumes this callback on the first page (Welcome). Opening the installer +; alone does not stop anything; the user must first leave the welcome page. +!define MUI_PAGE_CUSTOMFUNCTION_LEAVE EasyTierPrepareInstall +!define MUI_CUSTOMFUNCTION_GUIINIT EasyTierInstallerGuiInit + +Var EasyTierHelper +Var EasyTierInstallerPid +Var EasyTierDone +Var EasyTierFailed + +!macro EasyTierPrepare RESTORE + ${If} $EasyTierHelper != "" + Return + ${EndIf} + InitPluginsDir + File "/oname=$PLUGINSDIR\installer-processes.ps1" "${EASYTIER_INSTALLER_SCRIPT}" + System::Call 'kernel32::GetCurrentProcessId() i .r0' + StrCpy $EasyTierInstallerPid $0 + nsExec::ExecToStack '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\installer-processes.ps1" -InstallDir "$INSTDIR" -InstallerPid $EasyTierInstallerPid -RestoreService ${RESTORE}' + Pop $0 + Pop $1 + ${If} $0 != 0 + MessageBox MB_OK|MB_ICONSTOP "Could not close EasyTier:$\r$\n$1" /SD IDOK + Abort + ${EndIf} + IntOp $1 $1 + 0 + ${If} $1 != 0 + System::Call 'kernel32::OpenEventW(i 0x100000, i 0, w "Local\EasyTierInstaller-$EasyTierInstallerPid-done") p .r0' + StrCpy $EasyTierDone $0 + System::Call 'kernel32::OpenEventW(i 0x100000, i 0, w "Local\EasyTierInstaller-$EasyTierInstallerPid-failed") p .r0' + StrCpy $EasyTierFailed $0 + ${If} $EasyTierDone == 0 + ${OrIf} $EasyTierFailed == 0 + MessageBox MB_OK|MB_ICONSTOP "Could not wait for EasyTier to finish updating." /SD IDOK + Abort + ${EndIf} + StrCpy $EasyTierHelper 1 + ${EndIf} +!macroend + +!macro EasyTierFinish + ${If} $EasyTierHelper != "" + System::Call 'kernel32::OpenEventW(i 2, i 0, w "Local\EasyTierInstaller-$EasyTierInstallerPid-finish") p .r0' + ${If} $0 != 0 + System::Call 'kernel32::SetEvent(p r0)' + System::Call 'kernel32::CloseHandle(p r0)' + ${EndIf} + System::Call 'kernel32::WaitForSingleObject(p $EasyTierDone, i 65000) i .r0' + System::Call 'kernel32::WaitForSingleObject(p $EasyTierFailed, i 0) i .r1' + System::Call 'kernel32::CloseHandle(p $EasyTierDone)' + System::Call 'kernel32::CloseHandle(p $EasyTierFailed)' + StrCpy $EasyTierHelper "" + ${If} $0 != 0 + ${OrIf} $1 != 258 + MessageBox MB_OK|MB_ICONSTOP "Could not restore the EasyTier service. Please check its status in Windows Services." /SD IDOK + Abort + ${EndIf} + ${EndIf} +!macroend + +Function EasyTierPrepareInstall + !insertmacro EasyTierPrepare 1 +FunctionEnd + +Function EasyTierInstallerGuiInit + ; Passive installs skip Welcome, but still run the old uninstaller page. + ${GetOptions} $CMDLINE "/P" $0 + ${IfNot} ${Errors} + Call EasyTierPrepareInstall + ${EndIf} +FunctionEnd + +Function un.EasyTierPrepareUninstall + !insertmacro EasyTierPrepare 0 +FunctionEnd + +!macro NSIS_HOOK_PREINSTALL + ; Silent installs skip all pages. Also handle a fresh/custom install path. + Call EasyTierPrepareInstall +!macroend + +!macro NSIS_HOOK_POSTINSTALL + !insertmacro EasyTierFinish +!macroend + +!macro NSIS_HOOK_PREUNINSTALL + Call un.EasyTierPrepareUninstall +!macroend + +!macro NSIS_HOOK_POSTUNINSTALL + !insertmacro EasyTierFinish +!macroend diff --git a/easytier-gui/src-tauri/windows/installer-processes.ps1 b/easytier-gui/src-tauri/windows/installer-processes.ps1 new file mode 100644 index 00000000..641eface --- /dev/null +++ b/easytier-gui/src-tauri/windows/installer-processes.ps1 @@ -0,0 +1,264 @@ +param( + [ValidateSet('Prepare', 'Run')] + [string]$Action = 'Prepare', + [Parameter(Mandatory = $true)] + [string]$InstallDir, + [Parameter(Mandatory = $true)] + [int]$InstallerPid, + [int]$RestoreService = 1 +) + +$ErrorActionPreference = 'Stop' +$eventName = "Local\EasyTierInstaller-$InstallerPid" +$exePath = [IO.Path]::GetFullPath((Join-Path $InstallDir 'easytier-gui.exe')).Replace('\\?\', '') +Add-Type -AssemblyName System.ServiceProcess + +# Process.MainModule and WMI cannot reliably read an elevated GUI's path from +# the unelevated installer. Limited query access works across that boundary. +Add-Type @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; + +public static class EasyTierInstallerProcess +{ + [DllImport("kernel32.dll", SetLastError = true)] + public static extern IntPtr OpenProcess(uint access, bool inherit, int pid); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern bool QueryFullProcessImageName(IntPtr process, uint flags, + StringBuilder path, ref int size); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool TerminateProcess(IntPtr process, uint exitCode); + [DllImport("kernel32.dll")] + static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + [DllImport("kernel32.dll")] + public static extern bool CloseHandle(IntPtr handle); + + public static string Path(IntPtr process) + { + var path = new StringBuilder(32768); + int size = path.Capacity; + if (!QueryFullProcessImageName(process, 0, path, ref size)) + { + int error = Marshal.GetLastWin32Error(); + if (WaitForSingleObject(process, 0) == 0) return null; + throw new Win32Exception(error); + } + return path.ToString().Replace(@"\\?\", ""); + } + + public static bool Matches(int pid, string path, bool terminate) + { + // Keep the same handle for path verification, termination and waiting, + // so a recycled PID cannot cause us to terminate a different process. + IntPtr process = OpenProcess(terminate ? 0x101001u : 0x101000u, false, pid); + if (process == IntPtr.Zero) + { + int error = Marshal.GetLastWin32Error(); + if (error == 87) return false; // The process has already exited. + throw new Win32Exception(error); + } + try + { + if (!String.Equals(Path(process), path, StringComparison.OrdinalIgnoreCase)) + return false; + if (terminate) + { + if (!TerminateProcess(process, 0) && WaitForSingleObject(process, 0) != 0) + throw new Win32Exception(); + if (WaitForSingleObject(process, 30000) != 0) + throw new TimeoutException("EasyTier did not exit within 30 seconds."); + } + return true; + } + finally { CloseHandle(process); } + } +} +'@ + +function Get-AppProcesses { + foreach ($process in @(Get-Process -Name easytier-gui -ErrorAction SilentlyContinue)) { + if ([EasyTierInstallerProcess]::Matches($process.Id, $exePath, $false)) { + $process + } + } +} + +function Get-AppService { + $service = Get-WmiObject -Class Win32_Service -Filter "Name='easytier-gui'" + if ($service) { + $path = [regex]::Match($service.PathName, '^(?:"([^"]+)"|(\S+))') + $binary = ($path.Groups[1].Value + $path.Groups[2].Value).Replace('\\?\', '') + if ($binary -eq $exePath) { $service } + } +} + +function New-InstallerEvent([string]$name) { + $security = New-Object Security.AccessControl.EventWaitHandleSecurity + # RunAs may use another administrator account. The installer keeps its + # original identity; only these completion signals cross the boundary. + foreach ($sid in @( + [Security.Principal.WindowsIdentity]::GetCurrent().User, + (New-Object Security.Principal.SecurityIdentifier 'S-1-5-32-544') + )) { + $rule = New-Object Security.AccessControl.EventWaitHandleAccessRule( + $sid, 'FullControl', 'Allow') + $security.AddAccessRule($rule) + } + $created = $false + New-Object Threading.EventWaitHandle( + $false, [Threading.EventResetMode]::ManualReset, $name, [ref]$created, $security) +} + +try { + if ($Action -eq 'Prepare') { + $service = Get-AppService + $runningService = $service -and $service.State -ne 'Stopped' + $needsElevation = $runningService + $processes = @() + try { + $processes = @(Get-AppProcesses) + foreach ($process in $processes) { + $handle = [EasyTierInstallerProcess]::OpenProcess(0x101001, $false, $process.Id) + if ($handle -eq [IntPtr]::Zero) { $needsElevation = $true } + else { [void][EasyTierInstallerProcess]::CloseHandle($handle) } + } + } + catch { + if ($_.Exception.GetBaseException().NativeErrorCode -ne 5) { throw } + $needsElevation = $true + } + if (!$runningService -and !$processes.Count -and !$needsElevation) { + Write-Output 0 + exit 0 + } + + $ready = New-InstallerEvent "$eventName-ready" + $finish = New-InstallerEvent "$eventName-finish" + $done = New-InstallerEvent "$eventName-done" + $failed = New-InstallerEvent "$eventName-failed" + try { + $arguments = '-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass ' + + "-File `"$PSCommandPath`" -Action Run -InstallDir `"$InstallDir`" " + + "-InstallerPid $InstallerPid -RestoreService $RestoreService" + $start = @{ + FilePath = "$PSHOME\powershell.exe" + ArgumentList = $arguments + WindowStyle = 'Hidden' + PassThru = $true + } + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = New-Object Security.Principal.WindowsPrincipal($identity) + if ($needsElevation -and !$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $start.Verb = 'RunAs' + } + $helper = Start-Process @start + while (!$ready.WaitOne(100)) { + if ($helper.HasExited) { + throw 'Could not stop EasyTier.' + } + } + if ($failed.WaitOne(0)) { throw 'Could not stop EasyTier.' } + Write-Output $helper.Id + } + finally { + $ready.Dispose() + $finish.Dispose() + $done.Dispose() + $failed.Dispose() + } + } + else { + $ready = [Threading.EventWaitHandle]::OpenExisting("$eventName-ready") + $finish = [Threading.EventWaitHandle]::OpenExisting("$eventName-finish") + $done = [Threading.EventWaitHandle]::OpenExisting("$eventName-done") + $failed = [Threading.EventWaitHandle]::OpenExisting("$eventName-failed") + $installer = [Diagnostics.Process]::GetProcessById($InstallerPid) + $installerExit = New-Object Threading.ManualResetEvent($false) + $installerExit.SafeWaitHandle = New-Object Microsoft.Win32.SafeHandles.SafeWaitHandle( + $installer.Handle, $false) + $restart = $false + $controller = $null + $serviceProcess = $null + $startMode = $null + try { + $service = Get-AppService + if ($service -and $service.State -ne 'Stopped') { + $controller = New-Object ServiceProcess.ServiceController('easytier-gui') + $restart = $RestoreService -ne 0 -and $service.State -ne 'Stop Pending' -and + $service.StartMode -ne 'Disabled' + if ($service.StartMode -ne 'Disabled') { + # Released versions can exit without reporting Stopped, + # which queues SCM recovery even after an explicit stop. + # Keep recovery from starting the old binary during setup. + $originalStartMode = $service.StartMode + $result = $service.ChangeStartMode('Disabled').ReturnValue + if ($result -ne 0) { throw "Could not disable service startup: $result" } + $startMode = if ($originalStartMode -eq 'Auto') { 'Automatic' } else { 'Manual' } + } + if ($service.ProcessId) { + $serviceProcess = [Diagnostics.Process]::GetProcessById($service.ProcessId) + # Acquire the handle before stopping, while the PID still exists. + $null = $serviceProcess.Handle + } + if ($controller.Status -eq 'StartPending') { + $controller.WaitForStatus('Running', [TimeSpan]::FromSeconds(30)) + } + if ($controller.Status -ne 'StopPending') { $controller.Stop() } + $controller.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(30)) + if ($serviceProcess -and !$serviceProcess.WaitForExit(30000)) { + throw 'The EasyTier service process did not exit within 30 seconds.' + } + } + foreach ($process in @(Get-AppProcesses)) { + [void][EasyTierInstallerProcess]::Matches($process.Id, $exePath, $true) + } + [void]$ready.Set() + [void][Threading.WaitHandle]::WaitAny(@($finish, $installerExit)) + } + finally { + # Also restore after cancellation/failure, provided the executable + # still exists. A completed uninstall must not restart the service. + try { + if ($startMode) { + $result = $service.ChangeStartMode($startMode).ReturnValue + if ($result -ne 0) { throw "Could not restore service startup: $result" } + } + if ($restart -and (Test-Path -LiteralPath $exePath)) { + $controller.Refresh() + if ($controller.Status -eq 'StopPending') { + $controller.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(30)) + } + if ($controller.Status -eq 'Stopped') { + $controller.Start() + $controller.WaitForStatus('Running', [TimeSpan]::FromSeconds(30)) + } + } + } + finally { + if ($controller) { $controller.Dispose() } + if ($serviceProcess) { $serviceProcess.Dispose() } + $installerExit.Dispose() + $installer.Dispose() + } + } + } +} +catch { + if ($Action -eq 'Run' -and $failed) { [void]$failed.Set() } + Write-Error $_ -ErrorAction Continue + exit 1 +} +finally { + if ($Action -eq 'Run' -and $done) { + # Completion events remain accessible to the original user even when + # UAC ran the helper under a different administrator account. + [void]$done.Set() + [void]$ready.Set() + $ready.Dispose() + $finish.Dispose() + $done.Dispose() + $failed.Dispose() + } +} diff --git a/easytier-gui/src-tauri/windows/test-installer-processes.ps1 b/easytier-gui/src-tauri/windows/test-installer-processes.ps1 new file mode 100644 index 00000000..f86efdc6 --- /dev/null +++ b/easytier-gui/src-tauri/windows/test-installer-processes.ps1 @@ -0,0 +1,169 @@ +# Run with Windows PowerShell as administrator on a machine without an +# installed easytier-gui service. All processes and files are test fixtures. +# Use -GuiOnly from an unelevated shell to test the path that needs no UAC. +param([switch]$GuiOnly) + +$ErrorActionPreference = 'Stop' +$helperScript = Join-Path $PSScriptRoot 'installer-processes.ps1' +$testDir = Join-Path $env:TEMP ("EasyTier installer test " + [guid]::NewGuid()) +$appDir = Join-Path $testDir 'app' +$otherDir = Join-Path $testDir 'other' +$exe = Join-Path $appDir 'easytier-gui.exe' +$helper = $null +$gui = $null +$other = $null +$parent = $null +$createdService = $false + +function Assert($condition, [string]$message) { + if (!$condition) { throw $message } +} + +function Prepare([int]$parentPid = $PID, [int]$restore = 1) { + $output = & "$PSHOME\powershell.exe" -NoProfile -ExecutionPolicy Bypass ` + -File $helperScript -InstallDir $appDir -InstallerPid $parentPid -RestoreService $restore + Assert ($LASTEXITCODE -eq 0) "Prepare failed: $output" + $helperPid = [int]($output | Select-Object -Last 1) + if ($helperPid) { + $process = [Diagnostics.Process]::GetProcessById($helperPid) + $null = $process.Handle + return $process + } +} + +function Finish($process) { + if (!$process) { return } + $finish = [Threading.EventWaitHandle]::OpenExisting("Local\EasyTierInstaller-$PID-finish") + $done = [Threading.EventWaitHandle]::OpenExisting("Local\EasyTierInstaller-$PID-done") + $failed = [Threading.EventWaitHandle]::OpenExisting("Local\EasyTierInstaller-$PID-failed") + try { + [void]$finish.Set() + Assert ($done.WaitOne(65000)) 'The helper did not signal completion.' + Assert (!$failed.WaitOne(0)) 'The helper reported a failure.' + } + finally { + $finish.Dispose() + $done.Dispose() + $failed.Dispose() + } + Assert ($process.WaitForExit(5000)) 'The helper did not finish.' + Assert ($process.ExitCode -eq 0) "The helper failed: $($process.ExitCode)" + $process.Dispose() +} + +function Start-TestService { + Start-Service easytier-gui + (Get-Service easytier-gui).WaitForStatus('Running', [TimeSpan]::FromSeconds(10)) +} + +if (Get-Service easytier-gui -ErrorAction SilentlyContinue) { + throw 'An easytier-gui service already exists; refusing to touch it.' +} +try { + [void](New-Item -ItemType Directory -Path $appDir, $otherDir) + Add-Type -ReferencedAssemblies System.ServiceProcess -OutputAssembly $exe -OutputType ConsoleApplication @' +using System; +using System.ServiceProcess; +using System.Threading; + +class FixtureService : ServiceBase +{ + static bool legacy; + public FixtureService() { ServiceName = "easytier-gui"; } + protected override void OnStop() + { + // Model released EasyTier versions that exit without reporting Stopped. + if (legacy) Environment.Exit(0); + } + static void Main(string[] args) + { + if (args.Length > 0) + { + legacy = args[0] == "--legacy"; + ServiceBase.Run(new FixtureService()); + } + else Thread.Sleep(Timeout.Infinite); + } +} +'@ + Copy-Item $exe (Join-Path $otherDir 'easytier-gui.exe') + + $helper = Prepare + Assert (!$helper) 'An idle installation must not start a helper.' + Write-Host 'PASS: no running process or service' + + $gui = Start-Process $exe -PassThru + $other = Start-Process (Join-Path $otherDir 'easytier-gui.exe') -PassThru + $helper = Prepare + Assert $gui.HasExited 'The GUI was not stopped.' + Assert (!$other.HasExited) 'A GUI from a different installation was stopped.' + Finish $helper + $helper = $null + Write-Host 'PASS: close GUI, preserve a different installation' + if ($GuiOnly) { return } + + New-Service -Name easytier-gui -BinaryPathName "`"$exe`" --legacy" -StartupType Automatic | Out-Null + $createdService = $true + & "$env:SystemRoot\System32\sc.exe" failure easytier-gui reset= 0 actions= restart/1000 | Out-Null + Assert ($LASTEXITCODE -eq 0) 'Could not configure service recovery.' + Start-TestService + $gui = Start-Process $exe -PassThru + $helper = Prepare + Start-Sleep -Milliseconds 1500 + Assert ((Get-Service easytier-gui).Status -eq 'Stopped') 'The service was not stopped.' + Assert ((Get-WmiObject Win32_Service -Filter "Name='easytier-gui'").StartMode -eq 'Disabled') 'Service recovery was not inhibited.' + Assert $gui.HasExited 'The GUI alongside the service was not stopped.' + Move-Item $exe "$exe.old" + Copy-Item "$exe.old" $exe + Finish $helper + $helper = $null + Assert ((Get-Service easytier-gui).Status -eq 'Running') 'The running service was not restored.' + Assert ((Get-WmiObject Win32_Service -Filter "Name='easytier-gui'").StartMode -eq 'Auto') 'Automatic startup was not restored.' + Write-Host 'PASS: stop legacy service despite failure recovery, replace executable, restore service' + + Set-Service easytier-gui -StartupType Disabled + Stop-Service easytier-gui + (Get-Service easytier-gui).WaitForStatus('Stopped', [TimeSpan]::FromSeconds(10)) + Start-Sleep -Milliseconds 1500 + Set-Service easytier-gui -StartupType Manual + $helper = Prepare + Assert (!$helper) 'A stopped service must not start a helper.' + $gui = Start-Process $exe -PassThru + $helper = Prepare + Finish $helper + $helper = $null + Assert ((Get-Service easytier-gui).Status -eq 'Stopped') 'A previously stopped service was started.' + Write-Host 'PASS: preserve stopped service state' + + Start-TestService + $parent = Start-Process "$PSHOME\powershell.exe" -ArgumentList '-NoProfile -Command Start-Sleep 300' -WindowStyle Hidden -PassThru + $helper = Prepare $parent.Id + Assert ((Get-Service easytier-gui).Status -eq 'Stopped') 'The service was not stopped before cancellation.' + $parent.Kill() + Assert ($helper.WaitForExit(35000)) 'The helper did not handle installer exit.' + Assert ($helper.ExitCode -eq 0) 'The helper failed while handling installer exit.' + $helper.Dispose() + $helper = $null + Assert ((Get-Service easytier-gui).Status -eq 'Running') 'Cancellation did not restore the service.' + Assert ((Get-WmiObject Win32_Service -Filter "Name='easytier-gui'").StartMode -eq 'Manual') 'Manual startup was not restored.' + Write-Host 'PASS: restore service when the installer exits' + + $helper = Prepare $PID 0 + Finish $helper + $helper = $null + Assert ((Get-Service easytier-gui).Status -eq 'Stopped') 'Uninstallation restarted the service.' + Write-Host 'PASS: do not restart the service after uninstall' +} +finally { + if ($helper -and !$helper.HasExited) { Finish $helper } + if ($createdService) { + Set-Service easytier-gui -StartupType Disabled + Stop-Service easytier-gui -ErrorAction SilentlyContinue + & "$env:SystemRoot\System32\sc.exe" delete easytier-gui | Out-Null + } + foreach ($process in @($gui, $other, $parent)) { + if ($process -and !$process.HasExited) { $process.Kill(); $process.WaitForExit() } + if ($process) { $process.Dispose() } + } + Remove-Item -LiteralPath $testDir -Recurse -Force +} diff --git a/easytier/src/core.rs b/easytier/src/core.rs index 6b33a31c..f58c96e6 100644 --- a/easytier/src/core.rs +++ b/easytier/src/core.rs @@ -1018,8 +1018,8 @@ fn win_service_event_loop( let normal_status = ServiceStatus { service_type: ServiceType::OWN_PROCESS, - current_state: ServiceState::Running, - controls_accepted: ServiceControlAccept::STOP, + current_state: ServiceState::Stopped, + controls_accepted: ServiceControlAccept::empty(), exit_code: ServiceExitCode::Win32(0), checkpoint: 0, wait_hint: Duration::default(),