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 ' | ')
}