I am doing an un-attended uninstall of RabbitMQ using PowerShell on Windows. I retrieve and run the un-install with:
$RABBITMQ_PRODUCT_NAME = "RabbitMQ Server"
$rabbitMq = Get-ChildItem -Path HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall `
| Get-ItemProperty `
| Where-Object { $_.DisplayName -match $RABBITMQ_PRODUCT_NAME }
$rabbitUninstallProcess = Start-Process -FilePath $rabbitMq.UninstallString -ArgumentList "/S" -PassThru -ErrorAction Stop
# can't use the -Wait parameter or it hangs indefinitely, so we have to get the process, then WaitForExit.
$rabbitUninstallProcess.WaitForExit()
The problem: $rabbitUninstallProcess
launches the real uninstall process then exits. So the process I start exits (with a success code) before the uninstall is complete.
I can get the 'real' uninstall process, but now I am in a race condition and the process I find is not guaranteed to be the same one created by $rabbitUninstallProcess
. By inspecting the running process I was able to determine how to identify the real uninstall process.
$UNINSTALL_PROCESS_NAME = "Un_A"
$process = Get-Process -Name $UNINSTALL_PROCESS_NAME `
| Where-Object { $_.Product -match $RABBITMQ_PRODUCT_NAME }
$process.WaitForExit()
The $rabbitUninstallProcess
writes nothing to StandardOutput or StandardError.
How would I get a handle to THE process created by $rabbitUninstallProcess
rather than racing and guessing.
Here is my full script with comments and status output (and a bonus Erlang unattended uninstall that works as I would have expected):
$RABBITMQ_PRODUCT_NAME = "RabbitMQ Server"
$ERLANG_PRODUCT_NAME = "Erlang OTP"
$UNINSTALL_PROCESS_NAME = "Un_A"
$FAILURE = 9
$rabbitMq = Get-ChildItem -Path HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall `
| Get-ItemProperty `
| Where-Object { $_.DisplayName -match $RABBITMQ_PRODUCT_NAME }
$erlang = Get-ChildItem -Path HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall `
| Get-ItemProperty `
| Where-Object { $_.DisplayName -match $ERLANG_PRODUCT_NAME }
$rabbitMQDataDir = $env:RABBITMQ_BASE
if ($rabbitMq) {
$installedRabbitMQVersion = $rabbitMq.DisplayVersion
Write-Host "Removing $RABBITMQ_PRODUCT_NAME v$installedRabbitMQVersion"
# can't use the -Wait parameter or it hangs indefinitely, so we have to get the process, then WaitForExit.
$rabbitUninstallProcess = Start-Process -FilePath $rabbitMq.UninstallString -ArgumentList "/S" -PassThru -ErrorAction Stop
#This process only kicksoff the actual uninstall process, so wait for it to be done before looking for the uninstall process
$rabbitUninstallProcess.WaitForExit()
#The RabbitMQ uninstaller (from Nullsoft) process is called "Un_A".
#The Product property is what identifies this particular uninstall as RabbitMQ.
#Race condition, do we find the process before it finishes? Yes, but it is still a race.
$process = Get-Process -Name $UNINSTALL_PROCESS_NAME `
| Where-Object { $_.Product -match $RABBITMQ_PRODUCT_NAME }
if ($process) {
Write-Host "Waiting for uninstall process to finish"
$process.WaitForExit()
} else {
#If we didn't find the uninstall process, but it was running and the uninstall finishes after the script fails, a second run will pick up where this left off
Write-Host "Didn't find an uninstall process. Going to sleep then check if the uninstall happened. If RabbitMQ is still installed, it will fail after the re-check."
Start-Sleep -Seconds 60
}
$rabbitMqStillThere = Get-ChildItem HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall `
| Get-ItemProperty `
| Where-Object { $_.DisplayName -match $RABBITMQ_PRODUCT_NAME }
if ($rabbitMqStillThere) {
Write-Host "Unable to uninstall RabbitMQ. You may have to uninstall manually."
exit $FAILURE
} else {
Write-Host "$($rabbitMq.DisplayName) has been uninstalled."
}
} else {
Write-Host "$RABBITMQ_PRODUCT_NAME not installed moving on."
}
#need to clean the Rabbit MQ data directory.
if([string]::IsNullOrWhiteSpace($rabbitMQDataDir)){
#if the environment variable wasn't set, use our default.
$rabbitMQDataDir = "C:\ProgramData\RabbitMQ"
}
if (Test-Path -Path $rabbitMQDataDir -PathType Container) {
Write-Host "Removing RabbitMQ Data Dir $rabbitMQDataDir"
Remove-Item -Path $rabbitMQDataDir -Recurse -Force -ErrorAction Stop
}
#now remove erlang.
if ($erlang) {
$installedErlangVersion = $erlang.DisplayVersion
Write-Host "Removing $ERLANG_PRODUCT_NAME v$installedErlangVersion"
#because -Wait (ing) the erlang uninstall works
$erlangUninstallProcess = Start-Process -FilePath $erlang.UninstallString -ArgumentList "/S" -PassThru -Wait -ErrorAction Stop
if ($erlangUninstallProcess.ExitCode -ne 0) {
Write-Host "Error Removing Erlang "
exit $FAILURE
} else {
Write-Host "$($erlang.DisplayName) has been uninstalled."
}
} else {
Write-Host "$ERLANG_PRODUCT_NAME not installed moving on"
}
What I expected:
What I found:
-Wait
on the Start-Process
would wait forever.Start-Process ... -PassThru
then calling WaitForExit()
worked.I have been unable to deterministically find the child/descendant process started by the Start-Process
.
Start-Process
... -Wait
behaves differently from (Start-Process ... -PassThru)
.WaitForExit()
and Start-Process ... -PassThru |
Wait-Process
(see GitHub issue #15555 for details):
Start-Process ... -Wait
- unlike the latter two approaches - waits not just for the launched child process itself to terminate, but also waits for its children. In other words: It doesn't return until both the immediately launched process and any further processes (directly) launched from it have exited.Your symptom - an indefinite hang with Start-Process ... -Wait
- suggests that the directly launched uninstaller (bootstrapping) process not only launches additional processes, but that at least one of them seemingly doesn't terminate even after the actual uninstallation process has.
Workaround:
Wait for the directly launched, short-lived (bootstrapping) uninstaller process to terminate and obtain its PID (process ID).
Use this PID in a CIM call to identify all child processes of this process, by querying the list of running processes for those whose parent PID is the PID in question.
Among the filtered processes, you can then identify the actual uninstaller process via its .Product
property value, as before.
The following self-contained, simplified example demonstrates this technique:
In lieu of an uninstaller that launches child processes, a PowerShell CLI (powershell.exe
) call is used that launches a cmd.exe
window with a pause
command. The powershell.exe
process is short-lived and emulates the bootstrapping uninstaller; the asynchronously launched cmd.exe
process' window emulates the actual uninstaller process that outlives the powershell.exe
process.
The calling script will wait for the indirectly launched cmd.exe
process to exit, which requires the user to press a key in it.
# Launch the uninstaller and obtain and wait for only the immediately
# launched process, using .WaitForExit()
($launcherProcess =
Start-Process powershell.exe '-c Start-Process cmd ''/c pause''' -PassThru
).WaitForExit()
# Using the launcher process' PID, use CIM to query all processes whose
# .ParentProcess ID property contains this PID, which returns all
# (still-running) immediate child processes.
$launcherProcessChildren =
Get-CimInstance Win32_Process -Filter "ParentProcessId = $($launcherProcess.Id)"
# Among the child processes, look for the one of interest via its
# .Product property
$trueUninstallerProcess =
Get-Process -Id $launcherProcessChildren.ProcessId |
Where-Object Product -eq 'Microsoft® Windows® Operating System'
# Wait for its termination, then report the exit code.
$null = $trueUninstallerProcess.Handle # !! Needed to ensure .ExitCode can
# !! be accessed later - see below.
$trueUninstallerProcess.WaitForExit()
Write-Verbose -Verbose "Uninstaller process exited with code $($trueUninstallerProcess.ExitCode)."
Note:
$null = $trueUninstallerProcess.Handle
- a seeming no-op - is required to ensure that accessing .ExitCode
later returns the process exit code:
Accessing the .Handle
property causes the [System.Diagnostics.Process]
instance to update its internal state to obtain and cache the process handle.
Only a [System.Diagnostics.Process]
instance with a cached process handle is capable of later querying and reporting the process' exit code via its .ExitCode
property. Otherwise, an exception ("Process was not started by this object, so requested information cannot be determined.") is thrown, which PowerShell swallows, causing .ExitCode
to quietly evaluate to $null
.[1]
[1] See GitHub PR #20749 for background information, in the context of automatically providing caching of the process handle, behind the scenes, for processes launched directly by PowerShell via Start-Process
.