Private/Watch-SACProcessTree.ps1
|
function Watch-SACProcessTree { <# .SYNOPSIS Monitors a process and its entire descendant process tree until all processes have exited. .DESCRIPTION Acts as a "Supervisor" for uninstallation tasks. It tracks a root Process ID and recursively finds all child processes. It monitors the aggregate CPU and Memory usage of the entire tree. Includes safeguards against PID recycling and enforces hard/idle timeouts. .PARAMETER RootPID The Process ID of the root process to monitor. .PARAMETER DisplayName A friendly name for the process being monitored, used in UI feedback. .PARAMETER TimeoutMinutes The maximum total time (in minutes) the process tree is allowed to run before being force-killed. .PARAMETER IdleTimeoutMinutes The maximum time (in minutes) the aggregate CPU time can remain unchanged before the tree is considered hung and force-killed. #> [CmdletBinding(DefaultParameterSetName="ProcessObject")] param( [Parameter(Mandatory=$true, ParameterSetName="ProcessObject")] [System.Diagnostics.Process]$RootProcess, [Parameter(Mandatory=$true, ParameterSetName="ProcessID")] [int]$RootPID, [string]$DisplayName = "Process", [int]$TimeoutMinutes = 20, [int]$IdleTimeoutMinutes = 5, [string]$TailLogFile = $null, [string]$ComputerName = "localhost" ) $StartTime = Get-Date $ZeroCpuTime = $null $LastTotalCpu = $null $LastTotalMem = $null $StaticActivityTime = $null # We maintain a stateful dictionary of known processes: PID -> CreationDate # This completely eliminates PID recycling vulnerabilities. $KnownProcs = @{} if ($PSCmdlet.ParameterSetName -eq "ProcessObject") { $RootPID = $RootProcess.Id } $CimSession = if ($ComputerName -ne "localhost") { $opt = New-CimSessionOption -Protocol Dcom -ConnectTimeoutMs 5000 New-CimSession -ComputerName $ComputerName -SessionOption $opt -ErrorAction SilentlyContinue } else { $null } try { $cimParams = @{ Classname = "Win32_Process"; Filter = "ProcessId = $RootPID"; ErrorAction = "Stop" } if ($CimSession) { $cimParams["CimSession"] = $CimSession } $rootCim = Get-CimInstance @cimParams if ($rootCim) { $KnownProcs[$RootPID] = $rootCim.CreationDate } } catch { # If it exited before we could grab WMI info, we still add it so we can at least try to find children # spawned in the same second, though it's less reliable. $KnownProcs[$RootPID] = $StartTime } # Helper function to update the known process tree function Update-KnownTree { param($Session, $Computer) $cimParams = @{ Classname = "Win32_Process"; ErrorAction = "SilentlyContinue" } if ($Session) { $cimParams["CimSession"] = $Session } elseif ($Computer -ne "localhost") { $cimParams["ComputerName"] = $Computer } $allProcs = Get-CimInstance @cimParams if (-not $allProcs) { return $null } # Build a quick lookup dictionary for this loop $currentProcs = @{} foreach ($p in $allProcs) { $currentProcs[$p.ProcessId] = $p } $addedNew = $true while ($addedNew) { $addedNew = $false foreach ($p in $allProcs) { # If this process is NOT known, but its parent IS known... if (-not $KnownProcs.ContainsKey($p.ProcessId) -and $KnownProcs.ContainsKey($p.ParentProcessId)) { $parentCreationTime = $KnownProcs[$p.ParentProcessId] # PID Recycling Defense: The child must be created at or after the parent's creation time. if ($p.CreationDate -ge $parentCreationTime) { $KnownProcs[$p.ProcessId] = $p.CreationDate $addedNew = $true } } } } return $currentProcs } $isRemote = ($ComputerName -ne "localhost") -or (Test-SACRemoteSession) Write-Host "`n Monitoring process tree for $DisplayName..." -ForegroundColor Cyan while ($true) { $elapsed = (Get-Date) - $StartTime # Update our stateful tree with any new descendants $currentCimProcs = Update-KnownTree -Session $CimSession -Computer $ComputerName # Resilient Monitoring: Handle connection drops if ($null -eq $currentCimProcs -and $ComputerName -ne "localhost") { Write-Host "`n [!] Connection lost to $ComputerName. Retrying..." -ForegroundColor Yellow Start-Sleep -Seconds 5 if ($CimSession) { $CimSession | Remove-CimSession; $CimSession = New-CimSession -ComputerName $ComputerName -ErrorAction SilentlyContinue } continue } # 1. Check Hard Timeout if ($elapsed.TotalMinutes -ge $TimeoutMinutes) { Write-Host "`n [!] Hard timeout ($TimeoutMinutes m) reached for $DisplayName. Terminating tree." -ForegroundColor Red foreach ($pidToKill in $KnownProcs.Keys) { if ($ComputerName -ne "localhost") { Invoke-CimMethod -Query "SELECT * FROM Win32_Process WHERE ProcessId = $pidToKill" -MethodName Terminate -ErrorAction SilentlyContinue } else { try { Stop-Process -Id $pidToKill -Force -ErrorAction SilentlyContinue } catch {} } } break } # 2. Evaluate active processes from our known list $activeCount = 0 $totalCpu = 0 $totalMemMB = 0 foreach ($knownPid in $KnownProcs.Keys) { # Ensure the PID hasn't been recycled by checking CreationDate against our state $cimProc = $currentCimProcs[$knownPid] if ($null -ne $cimProc -and $cimProc.CreationDate -eq $KnownProcs[$knownPid]) { $activeCount++ # Resilient: Use CIM properties for remote monitoring, Get-Process for local if ($ComputerName -ne "localhost") { # WMI KernelModeTime/UserModeTime are 100ns units $totalCpu += ($cimProc.KernelModeTime + $cimProc.UserModeTime) / 10000000 $totalMemMB += ($cimProc.WorkingSetSize / 1MB) } else { try { $p = Get-Process -Id $knownPid -ErrorAction Stop $totalCpu += $p.CPU $totalMemMB += ($p.WorkingSet64 / 1MB) } catch {} } } } # Root logic: The root process must be truly dead # (Using CIM check if remote or if monitored by PID, otherwise .NET object) $rootIsDead = if ($ComputerName -ne "localhost" -or $PSCmdlet.ParameterSetName -eq "ProcessID") { $rootAlive = $null -ne $currentCimProcs -and $currentCimProcs.ContainsKey($RootPID) -and $currentCimProcs[$RootPID].CreationDate -eq $KnownProcs[$RootPID] -not $rootAlive } else { $RootProcess.HasExited } if ($rootIsDead -and $activeCount -eq 0) { Write-Host "`n [*] Process tree for $DisplayName has exited cleanly." -ForegroundColor Green break } # 3. Enhanced Zombie Detection (Idle CPU + Static Memory) # If an uninstaller hangs on a hidden dialog, CPU usage is 0 and WorkingSet is usually static. $isIdle = ($null -ne $LastTotalCpu -and $totalCpu -eq $LastTotalCpu) $isStaticMem = ($null -ne $LastTotalMem -and [math]::Abs($totalMemMB - $LastTotalMem) -lt 0.1) if ($isIdle -and $isStaticMem) { if ($null -eq $StaticActivityTime) { $StaticActivityTime = Get-Date } elseif (((Get-Date) - $StaticActivityTime).TotalMinutes -ge $IdleTimeoutMinutes) { Write-Host "`n [!] Zombie process detected (Idle CPU/Static Mem for $IdleTimeoutMinutes m) for $DisplayName. Terminating tree." -ForegroundColor Yellow foreach ($pidToKill in $KnownProcs.Keys) { if ($ComputerName -ne "localhost") { Invoke-CimMethod -Query "SELECT * FROM Win32_Process WHERE ProcessId = $pidToKill" -MethodName Terminate -ErrorAction SilentlyContinue } else { try { Stop-Process -Id $pidToKill -Force -ErrorAction SilentlyContinue } catch {} } } break } } else { $StaticActivityTime = $null $LastTotalCpu = $totalCpu $LastTotalMem = $totalMemMB } # 4. UI Feedback if (-not $isRemote) { $tailPart = "" if (-not [string]::IsNullOrWhiteSpace($TailLogFile) -and (Test-Path $TailLogFile)) { try { $stream = New-Object System.IO.FileStream($TailLogFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite) $reader = New-Object System.IO.StreamReader($stream) if ($stream.Length -gt 2048) { $stream.Seek(-2048, [System.IO.SeekOrigin]::End) | Out-Null } $lines = $reader.ReadToEnd() -split "`n" $lastLine = ($lines | Where-Object { $_.Trim() -ne "" })[-1] if ($lastLine) { $tailStr = $lastLine.Trim() if ($tailStr.Length -gt 60) { $tailStr = $tailStr.Substring(0, 57) + "..." } $tailPart = " | Tail: $tailStr" } $reader.Close() $stream.Close() } catch {} } $elapsedStr = "{0:mm\:ss}" -f $elapsed $memStr = [math]::Round($totalMemMB, 1) $statusLine = " [Elapsed: $elapsedStr] | [Active Procs: $activeCount] | [Tree Mem: ${memStr}MB]$tailPart" $statusLine = $statusLine.PadRight(130) Write-Host "`r$statusLine" -NoNewline -ForegroundColor DarkGray } Start-Sleep -Milliseconds 1500 } if (-not $isRemote) { Write-Host "" } # Newline cleanup if ($CimSession) { $CimSession | Remove-CimSession } } |