Private/Kinds/App.ps1
|
# The App Kind: one application, looked at from three sides. # # Where it is installed and whether that is a network share, what its processes are doing # to memory and to the two resource limits that kill Windows applications, and what the # event logs say it has been doing. A Technician arrives at a Customer Site told "Outlook # is slow"; this is the Check that answers whether Outlook is the problem. # # The crash half consumes what the Stability Check gathered, handed over as -Observed. In # the script this travelled through script-scoped state that the app checks read back, # guarded so that reordering the two made the crash analysis silently vanish. See # Private/Kind.ps1 for the contract that replaced it. # The per-process ceilings Windows enforces. A process gets 10,000 GDI and 10,000 USER # objects and then stops being able to draw, which surfaces to a Customer as an # application that dies every afternoon. $script:AppGuiResourceFlagGdi = 0 $script:AppGuiResourceFlagUser = 1 # What each exception code means. Kept beside the Judge because turning a code into a # sentence is the single most useful thing Gutcheck does with a crash: c0000006 in # particular names the cause outright, and it is the one a Technician meets most at a # Customer Site with an application on a share. $script:AppExceptionHints = @{ 'c0000005' = 'access violation (program bug, faulty add-in/driver, or bad RAM)' 'c0000006' = 'in-page error: executable/DLL could not be read - typical when the program runs from a network share and the connection drops, or disk errors' 'c0000374' = 'heap corruption' 'c0000409' = 'stack buffer overrun / fail-fast' 'c00000fd' = 'stack overflow' 'e0434352' = '.NET exception - see .NET Runtime events' 'c0000017' = 'out of memory' 'e06d7363' = 'unhandled C++ exception' } function Initialize-AppNativeMethod { <# .SYNOPSIS Defines the two Win32 calls the process Gatherer needs, once per session. .DESCRIPTION Called from the Gatherer rather than at import, because importing this module must define functions and do nothing else - compiling a type writes to disk on Windows PowerShell, and a Run is the only thing allowed to have side effects. #> [CmdletBinding()] param() if ('Gutcheck.Native' -as [type]) { return } Add-Type -Namespace Gutcheck -Name Native -MemberDefinition @' [DllImport("user32.dll")] public static extern uint GetGuiResources(IntPtr hProcess, uint uiFlags); [DllImport("kernel32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] public static extern bool IsWow64Process(IntPtr hProcess, out bool wow64); '@ } function Test-RemoteLocation { <# .SYNOPSIS Whether a path lives somewhere the network can take away mid-read. .DESCRIPTION A UNC path plainly does. A drive letter does when it is mapped, which has to be asked of the machine - so this belongs to a Gatherer, and the answer travels as data. An application running from either explains slow starts and c0000006 crashes, which is one of the few Findings that names its own cause. #> [CmdletBinding()] [OutputType([bool])] param([AllowNull()][AllowEmptyString()][string]$Path) if (-not $Path) { return $false } if ($Path -match '^\\\\') { return $true } if ($Path -notmatch '^([A-Za-z]):') { return $false } $letter = $Matches[1].ToUpperInvariant() if ($null -eq $script:AppDriveTypeCache) { $script:AppDriveTypeCache = @{} } if (-not $script:AppDriveTypeCache.ContainsKey($letter)) { $disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='${letter}:'" -ErrorAction SilentlyContinue $script:AppDriveTypeCache[$letter] = $disk.DriveType } # DriveType 4 is a network drive, as Win32_LogicalDisk numbers them. $script:AppDriveTypeCache[$letter] -eq 4 } function Get-AppInstallation { <# .SYNOPSIS Everywhere this machine thinks the application is. Judges none of it. #> [CmdletBinding()] param([hashtable]$Parameters = @{}) $exe = Get-Parameter $Parameters 'Exe' '' $appx = Get-Parameter $Parameters 'Appx' '' $match = Get-Parameter $Parameters 'Match' '' $found = @() # App Paths: what Windows uses when the application is started by name. if ($exe) { foreach ($root in 'HKLM:', 'HKCU:') { $path = (Get-ItemProperty "$root\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\$exe" ` -ErrorAction SilentlyContinue).'(default)' if (-not $path) { continue } $path = $path.Trim('"') $version = 'file missing' if (Test-Path $path) { $version = (Get-Item $path).VersionInfo.ProductVersion } $found += [pscustomobject]@{ Source = 'App Paths'; Name = $exe; Version = $version; Path = $path Remote = Test-RemoteLocation -Path $path } } } if ($appx) { foreach ($package in @(Get-AppxPackage -Name $appx -ErrorAction SilentlyContinue)) { $found += [pscustomobject]@{ Source = 'Store app'; Name = $package.Name; Version = "$($package.Version)" Path = $package.InstallLocation; Remote = $false } } } if ($match) { foreach ($program in @(Get-InstalledProgram | Where-Object { $_.DisplayName -match $match })) { $found += [pscustomobject]@{ Source = 'Installed programs'; Name = $program.DisplayName; Version = $program.DisplayVersion Path = $program.InstallLocation; Remote = Test-RemoteLocation -Path $program.InstallLocation } } foreach ($shortcut in @(Get-StartMenuShortcut | Where-Object { $_.Name -match $match -and $_.Target })) { $found += [pscustomobject]@{ Source = 'Shortcut'; Name = $shortcut.Name; Version = '' Path = "$($shortcut.Target) $($shortcut.Arguments)".Trim() Remote = Test-RemoteLocation -Path $shortcut.Target } } } # Name and version are part of what makes an installation distinct: many installers # record no InstallLocation, and two different programs with an empty path - an old and # a new VPN client side by side - would otherwise collapse into one. @($found | Sort-Object Source, Name, Version, Path -Unique) } function Get-InstalledProgram { <# .SYNOPSIS Everything in the uninstall registry, read once per Run. .DESCRIPTION Cached because a Run with eight applications selected would otherwise walk three registry hives eight times for an answer that cannot change during a Run. #> [CmdletBinding()] param() if ($null -eq $script:AppInstalledProgramCache) { $script:AppInstalledProgramCache = @(Get-ItemProperty ` 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName }) } $script:AppInstalledProgramCache } function Get-StartMenuShortcut { <# .SYNOPSIS Desktop and Start menu shortcuts with their targets, read once per Run. .DESCRIPTION The last place an application shows up when it was never properly installed, which is exactly the case where a Technician cannot find it and the Customer insists it is there. The target is also where a share-hosted application gives itself away. #> [CmdletBinding()] param() if ($null -ne $script:AppShortcutCache) { return $script:AppShortcutCache } $shell = $null try { $shell = New-Object -ComObject WScript.Shell } catch { } if (-not $shell) { $script:AppShortcutCache = @(); return $script:AppShortcutCache } $directories = @( [Environment]::GetFolderPath('Desktop') [Environment]::GetFolderPath('CommonDesktopDirectory') [Environment]::GetFolderPath('StartMenu') [Environment]::GetFolderPath('CommonStartMenu') ) $script:AppShortcutCache = @(foreach ($directory in $directories) { if (-not $directory -or -not (Test-Path $directory)) { continue } Get-ChildItem $directory -Filter *.lnk -Recurse -ErrorAction SilentlyContinue | ForEach-Object { try { $link = $shell.CreateShortcut($_.FullName) [pscustomobject]@{ Name = $_.BaseName; Target = $link.TargetPath; Arguments = $link.Arguments } } catch { } } }) $script:AppShortcutCache } function Get-AppProcess { <# .SYNOPSIS The application's running processes and what they are consuming. #> [CmdletBinding()] param([Parameter(Mandatory)][AllowEmptyString()][string]$Pattern) if (-not $Pattern) { return @() } Initialize-AppNativeMethod @(Get-Process -ErrorAction SilentlyContinue | Where-Object { $_.ProcessName -match $Pattern } | ForEach-Object { $gdi = $null; $user = $null; $bits = $null; $path = $null # Every one of these throws on a process this session may not open, which on a # Target Machine means anything running as another user. Absent is reported as # absent; what an absent reading means is the Judge's business. try { $handle = $_.Handle $gdi = [Gutcheck.Native]::GetGuiResources($handle, $script:AppGuiResourceFlagGdi) $user = [Gutcheck.Native]::GetGuiResources($handle, $script:AppGuiResourceFlagUser) $wow64 = $false if ([Gutcheck.Native]::IsWow64Process($handle, [ref]$wow64)) { $bits = $(if ($wow64) { 32 } else { 64 }) } } catch { } try { $path = $_.Path } catch { } [pscustomobject]@{ Process = $_.ProcessName ProcessId = $_.Id PrivateMB = [math]::Round($_.PrivateMemorySize64 / 1MB) Handles = $_.HandleCount Gdi = $gdi User = $user Bits = $bits CpuSeconds = $(try { [math]::Round($_.TotalProcessorTime.TotalSeconds) } catch { $null }) Path = $path Remote = Test-RemoteLocation -Path $path } }) } function Get-AppData { <# .SYNOPSIS Everything about one application. Decides nothing, including which crashes are its. .PARAMETER Observed What the Checks before this one gathered, keyed by Kind. The crash rows come from the Stability Check and the installed memory from the System Check; both are copied across unfiltered, because which rows belong to this application depends on a pattern and could change a Severity, which makes it the Judge's call. #> [CmdletBinding()] [OutputType([psobject])] param( [hashtable]$Parameters = @{}, [AllowNull()][hashtable]$Observed = @{} ) $pattern = Get-Parameter $Parameters 'Process' '' $name = Get-Parameter $Parameters 'App' $pattern $stability = $null $system = $null if ($Observed) { if ($Observed.ContainsKey('Stability')) { $stability = $Observed['Stability'] } if ($Observed.ContainsKey('System')) { $system = $Observed['System'] } } [pscustomobject]@{ PSTypeName = 'Gutcheck.Data.App' App = $name ProcessPattern = $pattern Installations = @(Get-AppInstallation -Parameters $Parameters) Processes = @(Get-AppProcess -Pattern $pattern) TotalPhysicalMemoryBytes = Get-DataProperty $system 'TotalPhysicalMemoryBytes' # Absent when the Stability Check did not run, which is a different fact from an # application that has not crashed. The Judge says which it is. StabilityObserved = $null -ne $stability ObservedCrashes = Get-DataCollection $stability 'Crashes' ObservedHangs = Get-DataCollection $stability 'Hangs' ObservedRuntimeFaults = Get-DataCollection $stability 'RuntimeFaults' CrashWindowDays = Get-DataProperty $stability 'Days' } } function ConvertTo-AppFinding { [CmdletBinding()] [OutputType([psobject])] param( [AllowNull()]$Data, [hashtable]$Parameters = @{} ) New-AppInstallationFinding -Data $Data -Parameters $Parameters New-AppResourceFinding -Data $Data -Parameters $Parameters New-AppCrashFinding -Data $Data -Parameters $Parameters } function ConvertTo-AppSection { [CmdletBinding()] [OutputType([psobject])] param([AllowNull()]$Data) $name = Get-DataProperty $Data 'App' $processes = Get-DataCollection $Data 'Processes' if ($processes.Count) { New-Section -Title ((Get-Text 'Title.App.Processes') -f $name) -Row @( $processes | Sort-Object PrivateMB -Descending | Select-Object -First 20 ` Process, ProcessId, PrivateMB, Handles, Gdi, User, Bits, CpuSeconds, Path ) } $crashes = Select-AppRow -Row (Get-DataCollection $Data 'ObservedCrashes') ` -Pattern (Get-DataProperty $Data 'ProcessPattern') if ($crashes.Count) { New-Section -Title ((Get-Text 'Title.App.CrashesLast15') -f $name) -Row @( $crashes | Sort-Object Time -Descending | Select-Object -First 15 ` Time, App, Version, Module, ModuleVer, Exception, AppPath ) } } function Select-AppRow { <# .SYNOPSIS The event rows belonging to this application, by process pattern. .DESCRIPTION Event logs name the executable with its extension and a Check Definition's pattern is written against the process name without one, which is how the script matched them and how a Definition written for one reads for the other. #> [CmdletBinding()] [OutputType([object[]])] param( [AllowNull()][AllowEmptyCollection()]$Row, [AllowNull()][AllowEmptyString()][string]$Pattern ) if (-not $Pattern) { return , @() } , @(@($Row) | Where-Object { $_ -and $_.App -and (($_.App -replace '\.exe$', '') -match $Pattern) }) } function New-AppInstallationFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $installations = Get-DataCollection $Data 'Installations' if (-not $installations.Count) { return New-Finding -Category Apps -Check (Get-Text 'Check.App.Installation') -Severity INFO ` -Value (Get-Text 'Value.App.NothingFound') ` -Hint (Get-Text 'Hint.App.TheApplicationWasNotFound') } foreach ($installation in $installations) { $remote = [bool]$installation.Remote New-Finding -Category Apps -Check $installation.Source ` -Severity $(if ($remote) { 'WARN' } else { 'INFO' }) ` -Value ('{0} {1} {2}' -f $installation.Name, $installation.Version, $installation.Path).Trim() ` -Hint $(if ($remote) { 'Started from a network share: SMB/Wi-Fi/VPN hiccups can crash it (c0000006) and it loads slowly - prefer a local installation' } else { '' }) } } function New-AppResourceFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $gdiWarn = Get-Parameter $Parameters 'GuiObjectFailAbove' 8000 $handleWarn = Get-Parameter $Parameters 'HandleWarnAbove' 10000 $largeProcess = Get-Parameter $Parameters 'Large32BitProcessMB' 1500 $ramShare = Get-Parameter $Parameters 'RamSharePercentWarnAbove' 40 $pattern = Get-DataProperty $Data 'ProcessPattern' $processes = Get-DataCollection $Data 'Processes' if (-not $processes.Count) { return New-Finding -Category Apps -Check (Get-Text 'Check.App.RunningProcesses') -Severity INFO ` -Value ("none (pattern '{0}')" -f $pattern) ` -Hint (Get-Text 'Hint.App.StartTheAppAndUse') } $totalMB = (@($processes | ForEach-Object { ConvertTo-Number $_.PrivateMB } | Where-Object { $null -ne $_ }) | Measure-Object -Sum).Sum $maxGdi = Get-AppMaximum -Row $processes -Property 'Gdi' $maxUser = Get-AppMaximum -Row $processes -Property 'User' $maxHandles = Get-AppMaximum -Row $processes -Property 'Handles' $bits = (@($processes | ForEach-Object { $_.Bits } | Where-Object { $_ } | Select-Object -Unique | Sort-Object) -join '/') # A 32-bit process near its address-space ceiling is about to die and cannot be helped # by adding RAM, which is the advice it would otherwise attract. $large32 = @($processes | Where-Object { (ConvertTo-Number $_.Bits) -eq 32 -and (ConvertTo-Number $_.PrivateMB) -gt $largeProcess }) $ramMB = $null $installed = ConvertTo-Number (Get-DataProperty $Data 'TotalPhysicalMemoryBytes') if ($null -ne $installed -and $installed -gt 0) { $ramMB = $installed / 1MB } # Ordered worst first: the GDI ceiling and the 32-bit ceiling both end in a crash, so # neither is allowed to be described as merely a high handle count. $severity = 'OK' $hint = '' if (($null -ne $maxGdi -and $maxGdi -gt $gdiWarn) -or ($null -ne $maxUser -and $maxUser -gt $gdiWarn)) { $severity = 'FAIL' $hint = (Get-Text 'Hint.App.GdiUserNearLimit') } elseif ($large32.Count) { $severity = 'FAIL' $hint = (Get-Text 'Hint.App.Bit32NearMemoryLimit') } elseif ($null -ne $maxHandles -and $maxHandles -gt $handleWarn) { $severity = 'WARN' $hint = (Get-Text 'Hint.App.VeryHighHandleCount') } elseif ($null -ne $ramMB -and $totalMB -gt ($ramMB * $ramShare / 100)) { $severity = 'WARN' $hint = (Get-Text 'Hint.App.LargeShareOfRam') } New-Finding -Category Apps -Check (Get-Text 'Check.App.Resources') -Severity $severity ` -Value ((Get-Text 'Value.App.Resources') -f $processes.Count, $totalMB, $maxGdi, $maxUser, $maxHandles, $bits) -Hint $hint # One Finding per distinct share, because the path is the thing to go and change. foreach ($path in @($processes | Where-Object { $_.Remote -and $_.Path } | Select-Object -ExpandProperty Path -Unique)) { New-Finding -Category Apps -Check (Get-Text 'Check.App.RunningFrom') -Severity WARN -Value $path ` -Hint (Get-Text 'Hint.App.ExecutableRunsFromANetwork') } } function Get-AppMaximum { <# .SYNOPSIS The largest readable value of a property across the processes, or $null if none is. #> [CmdletBinding()] param([AllowNull()][AllowEmptyCollection()]$Row, [Parameter(Mandatory)][string]$Property) $values = @(@($Row) | ForEach-Object { ConvertTo-Number $_.$Property } | Where-Object { $null -ne $_ }) if (-not $values.Count) { return $null } ($values | Measure-Object -Maximum).Maximum } function New-AppCrashFinding { [CmdletBinding()] param([AllowNull()]$Data, [hashtable]$Parameters) $failAbove = Get-Parameter $Parameters 'AppCrashFailAbove' 0 if (-not (Get-DataProperty $Data 'StabilityObserved')) { # No event data reached this Check, so it has nothing to say. Reporting "none # logged" here would be reporting a clean application on no evidence at all. return New-UnavailableFinding -Category Apps -Check (Get-Text 'Check.App.CrashesHangs') ` -Hint (Get-Text 'Hint.App.NoEventDataReachedThis') } $pattern = Get-DataProperty $Data 'ProcessPattern' $days = Get-DataProperty $Data 'CrashWindowDays' $crashes = Select-AppRow -Row (Get-DataCollection $Data 'ObservedCrashes') -Pattern $pattern $hangs = Select-AppRow -Row (Get-DataCollection $Data 'ObservedHangs') -Pattern $pattern $faults = Select-AppRow -Row (Get-DataCollection $Data 'ObservedRuntimeFaults') -Pattern $pattern if (-not ($crashes.Count + $hangs.Count + $faults.Count)) { return New-Finding -Category Apps -Check (Get-Text 'Check.App.CrashesHangs') -Severity OK ` -Value $(if ($days) { ((Get-Text 'Value.App.NoneLoggedInDays') -f $days) } else { (Get-Text 'Value.App.NoneLogged') }) } # The faulting module is the lead: it is usually an add-in DLL or a driver, and it # names the thing to remove rather than the application that died of it. $modules = (@($crashes | Group-Object { '{0} / {1}' -f $_.Module, $_.Exception } | Sort-Object Count -Descending | Select-Object -First 4 | ForEach-Object { '{0} x{1}' -f $_.Name, $_.Count }) -join '; ') # An exception code is the best Hint there is, but there is not always one: an # application that only ever hangs has no crash to decode, and a crash can carry a # code this table does not know. Every Finding that is not OK owes the Technician # something to do, so the generic advice stands in rather than an empty cell. $hint = Get-AppExceptionHint -Crash $crashes if (-not $hint) { $hint = (Get-Text 'Hint.App.CheckFaultingModule') } $shares = @($crashes | Where-Object { $_.AppPath } | Where-Object { $_.AppPath -match '^\\\\' } | Select-Object -ExpandProperty AppPath -Unique) if ($shares.Count) { $hint = ('Runs from a network share ({0}) - network drops crash it. {1}' -f ($shares -join ', '), $hint).Trim() } # A hang is the application stopping; a crash is the application dying. Both matter, # but only one of them loses the Customer's work. $severity = 'WARN' if (($crashes.Count + $faults.Count) -gt $failAbove) { $severity = 'FAIL' } New-Finding -Category Apps -Check (Get-Text 'Check.App.CrashesNETErrorsHangs') -Severity $severity ` -Value ('{0} / {1} / {2} {3}' -f $crashes.Count, $faults.Count, $hangs.Count, $modules).Trim() ` -Hint $hint } function Get-AppExceptionHint { <# .SYNOPSIS The exception codes in these crashes, turned into sentences. #> [CmdletBinding()] [OutputType([string])] param([AllowNull()][AllowEmptyCollection()]$Crash) $codes = @(@($Crash) | ForEach-Object { ("$($_.Exception)" -replace '^0x', '').ToLowerInvariant() } | Where-Object { $_ } | Select-Object -Unique) (@($codes | ForEach-Object { if ($script:AppExceptionHints.ContainsKey($_)) { '{0} = {1}' -f $_, $script:AppExceptionHints[$_] } }) -join ' | ') } |