Private/Kinds/DiskTest.ps1

# The DiskTest Kind: what the system disk actually does, rather than what it claims.
#
# The Storage Kind reads what Windows says about the disk. This one writes to it. The two
# disagree often enough to be worth both: a drive that reports Healthy and idle can still
# take 40 ms to commit a 4 KB write once real-time scanning and encryption are in the way,
# and that number is what a Technician needs when Outlook feels slow on a healthy machine.

# The scratch file is written in 4 MB blocks, and the latency probe writes 4 KB at a time
# because that is the shape of the writes Outlook and a database client actually make.
$script:DiskTestBlockBytes       = 4MB
$script:DiskTestSmallWriteBytes  = 4096
$script:DiskTestSmallWriteCount  = 500

# A test that fills the disk it is measuring is worse than no test, so it wants room for
# the file three times over before it starts.
$script:DiskTestFreeSpaceFactor  = 3

function Get-DiskTestData {
    <#
    .SYNOPSIS
        Measures the system disk by writing to it. Decides nothing about what it measures.
    .DESCRIPTION
        The scratch file is removed in a finally block, so an interrupted Run does not
        leave a gigabyte behind on a Customer's machine. Whether there was room to run at
        all is reported as data - the Judge decides what an unrun measurement means.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $sizeMB = [int](Get-Parameter $Parameters 'SizeMB' 1024)
    $path   = Resolve-DiskTestPath -Path (Get-Parameter $Parameters 'Path' $env:TEMP)

    $required = [long]$sizeMB * 1MB * $script:DiskTestFreeSpaceFactor
    $free     = $null
    $qualifier = ''
    if ("$path" -match '^([A-Za-z]:)') { $qualifier = $Matches[1] }
    if ($qualifier) {
        $volume = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$qualifier'" -ErrorAction SilentlyContinue
        $free   = $volume.FreeSpace
    }

    $data = [ordered]@{
        PSTypeName          = 'Gutcheck.Data.DiskTest'
        Path                = $path
        SizeMB              = $sizeMB
        FreeBytes           = $free
        RequiredBytes       = $required
        Measured            = $false
        SequentialWriteMBps = $null
        SequentialReadMBps  = $null
        SyncWriteLatencyMs  = @()
    }

    if ($null -eq $free -or $free -lt $required) { return [pscustomobject]$data }

    $file   = [IO.Path]::Combine($path, ('gutcheck_disktest_{0}.tmp' -f [guid]::NewGuid()))
    $blocks = [int]($sizeMB * 1MB / $script:DiskTestBlockBytes)
    if ($blocks -lt 1) { $blocks = 1 }

    $buffer = New-Object byte[] $script:DiskTestBlockBytes
    (New-Object Random).NextBytes($buffer)

    $stream = $null
    try {
        # WriteThrough, so the measurement is of the disk rather than of the write cache.
        $stream = New-Object IO.FileStream($file, [IO.FileMode]::Create, [IO.FileAccess]::ReadWrite,
                                           [IO.FileShare]::None, 4096, [IO.FileOptions]::WriteThrough)

        $watch = [Diagnostics.Stopwatch]::StartNew()
        $writing = Get-Text 'Console.Progress.DiskWrite'
        $shownPercent = -1
        for ($i = 0; $i -lt $blocks; $i++) {
            # Drawn with the stopwatch paused, and only when the percentage moves, so the
            # throughput measured is the disk's and not the console's.
            $percent = [int][math]::Floor($i * 100 / $blocks)
            if ($percent -ne $shownPercent) {
                $watch.Stop()
                Write-CheckProgress -Status $writing -Step $i -Of $blocks
                $watch.Start()
                $shownPercent = $percent
            }
            $stream.Write($buffer, 0, $script:DiskTestBlockBytes)
        }
        $stream.Flush($true)
        $watch.Stop()
        if ($watch.Elapsed.TotalSeconds -gt 0) {
            $data.SequentialWriteMBps = ($blocks * $script:DiskTestBlockBytes / 1MB) / $watch.Elapsed.TotalSeconds
        }

        # Small synchronous writes at random offsets: what an OST or a database file does.
        $small = New-Object byte[] $script:DiskTestSmallWriteBytes
        (New-Object Random).NextBytes($small)
        $random    = New-Object Random
        $maxOffset = [int]([long]$blocks * $script:DiskTestBlockBytes / $script:DiskTestSmallWriteBytes) - 1
        if ($maxOffset -lt 1) { $maxOffset = 1 }

        $latencies = New-Object System.Collections.Generic.List[double]
        $latencyStatus = Get-Text 'Console.Progress.DiskLatency'
        for ($i = 0; $i -lt $script:DiskTestSmallWriteCount; $i++) {
            # Outside the stopwatch, which starts below, so the latency is the disk's alone.
            Write-CheckProgress -Status $latencyStatus -Step $i -Of $script:DiskTestSmallWriteCount
            $stream.Position = [long]$random.Next(0, $maxOffset) * $script:DiskTestSmallWriteBytes
            $one = [Diagnostics.Stopwatch]::StartNew()
            $stream.Write($small, 0, $script:DiskTestSmallWriteBytes)
            $stream.Flush($true)
            $latencies.Add($one.Elapsed.TotalMilliseconds)
        }
        $data.SyncWriteLatencyMs = @($latencies)

        $stream.Dispose(); $stream = $null

        $watch.Restart()
        $read = [IO.File]::OpenRead($file)
        try { while ($read.Read($buffer, 0, $script:DiskTestBlockBytes) -gt 0) { } }
        finally { $read.Dispose() }
        $watch.Stop()
        if ($watch.Elapsed.TotalSeconds -gt 0) {
            $data.SequentialReadMBps = ($blocks * $script:DiskTestBlockBytes / 1MB) / $watch.Elapsed.TotalSeconds
        }

        $data.Measured = $true
    }
    finally {
        if ($stream) { $stream.Dispose() }
        # Through .NET like everything else done to this file, so that whatever let the
        # file be written also lets it be removed. A gigabyte left in a Customer's
        # profile because a cmdlet read the path differently is not an acceptable failure.
        try { [IO.File]::Delete($file) } catch { }
    }

    [pscustomobject]$data
}

function Resolve-DiskTestPath {
    <#
    .SYNOPSIS
        The directory the test file goes in: the one asked for, or the profile's own
        temporary directory when that one is not there.
    .DESCRIPTION
        %TEMP% is handed to a process in whatever form the logon gave it, and for a profile
        whose folder is not a valid 8.3 name - max.mustermann - that is a short path such as
        C:\Users\MAXMUS~1.MUS\AppData\Local\Temp. PowerShell's file system provider expands
        short names itself and gives up with "an object at the specified path does not
        exist" where Windows would have opened the file. So no cmdlet touches this path:
        it is checked and used through .NET, and when it cannot be found the long form of
        the same directory is used instead.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([AllowNull()][AllowEmptyString()][string]$Path)

    if ($Path -and [IO.Directory]::Exists($Path)) { return $Path }

    $fallback = [IO.Path]::Combine([Environment]::GetFolderPath('LocalApplicationData'), 'Temp')
    if ([IO.Directory]::Exists($fallback)) { return $fallback }
    $Path
}

function Get-DiskTestJudgement {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{}
    )

    if (-not (Get-DataProperty $Data 'Measured')) {
        return New-DiskTestSkippedFinding -Data $Data
    }

    New-SequentialWriteFinding  -Data $Data -Parameters $Parameters
    New-SyncWriteLatencyFinding -Data $Data -Parameters $Parameters
    New-SequentialReadFinding   -Data $Data -Parameters $Parameters
}

function New-DiskTestSkippedFinding {
    <#
    .SYNOPSIS
        Why nothing was measured. A Check that did not run belongs in the Report.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data)

    $free     = ConvertTo-Number (Get-DataProperty $Data 'FreeBytes')
    $required = ConvertTo-Number (Get-DataProperty $Data 'RequiredBytes')

    if ($null -eq $free) {
        return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.DiskTest.DiskTest') `
            -Hint (Get-Text 'Hint.DiskTest.TheScratchVolumeDidNot')
    }

    New-Finding -Category Storage -Check (Get-Text 'Check.DiskTest.DiskTest') -Severity INFO `
        -Value ((Get-Text 'Value.DiskTest.NotEnoughSpace') -f ($free / 1GB), ($required / 1GB)) `
        -Hint (Get-Text 'Hint.DiskTest.FreeUpSpaceAndRerun')
}

function New-SequentialWriteFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    # Graded downwards: it is the small number that is the problem.
    $warnBelow = Get-Parameter $Parameters 'SequentialWriteWarnBelowMBps' 250
    $failBelow = Get-Parameter $Parameters 'SequentialWriteFailBelowMBps' 80

    $rate = ConvertTo-Number (Get-DataProperty $Data 'SequentialWriteMBps')
    if ($null -eq $rate) {
        return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.DiskTest.SequentialWriteWriteThrough') `
            -Hint (Get-Text 'Hint.DiskTest.TheWriteWasNotTimed')
    }

    $severity = Get-SeverityBelow $rate $warnBelow $failBelow
    $signal = @()
    if ($severity -in 'WARN', 'FAIL') { $signal = @('disk-write-slow') }
    New-Finding -Category Storage -Check (Get-Text 'Check.DiskTest.SequentialWriteWriteThrough') `
        -Severity $severity -Value ('{0:N0} MB/s' -f $rate) `
        -Hint (Get-Text 'Hint.DiskTest.SATASSD300500NVMe') -Signal $signal
}

function New-SyncWriteLatencyFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'SyncWriteLatencyWarnMs' 8
    $fail = Get-Parameter $Parameters 'SyncWriteLatencyFailMs' 20

    $samples = (Get-DataCollection $Data 'SyncWriteLatencyMs')
    $average = Get-SampleAverage $samples
    if ($null -eq $average) {
        return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.DiskTest.4KSyncWriteLatency') `
            -Hint (Get-Text 'Hint.DiskTest.NoSmallWriteLatencyWas')
    }

    # Judged on the average, reported with the tail: one stall in five hundred writes is
    # not a sluggish disk, but a Technician still wants to see that it happened.
    $p95     = Get-SamplePercentile $samples 95
    $worst   = Get-SamplePercentile $samples 100

    $severity = Get-Severity $average $warn $fail
    $signal = @()
    if ($severity -in 'WARN', 'FAIL') { $signal = @('disk-write-slow') }
    New-Finding -Category Storage -Check (Get-Text 'Check.DiskTest.4KSyncWriteLatency') `
        -Severity $severity `
        -Value ((Get-Text 'Value.DiskTest.Latency') -f $average, $p95, $worst) `
        -Hint (Get-Text 'Hint.DiskTest.HighLatencySluggishOutlookAnd') -Signal $signal
}

function New-SequentialReadFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $rate = ConvertTo-Number (Get-DataProperty $Data 'SequentialReadMBps')
    if ($null -eq $rate) {
        return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.DiskTest.SequentialRead') `
            -Hint (Get-Text 'Hint.DiskTest.TheReadWasNotTimed')
    }

    # Never graded: the file it reads back was just written, so the cache serves much of
    # it and the number says more about RAM than about the disk.
    New-Finding -Category Storage -Check (Get-Text 'Check.DiskTest.SequentialRead') -Severity INFO `
        -Value ('{0:N0} MB/s' -f $rate) -Hint (Get-Text 'Hint.DiskTest.MayBeInflatedByThe')
}

function ConvertTo-DiskTestFinding {
    <#
    .SYNOPSIS
        The Judge. What it measured, and - where that is not in order - that the machine
        was on battery when it was measured, which Windows makes slower.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{},
        [AllowNull()]$Situation
    )

    Get-DiskTestJudgement -Data $Data -Parameters $Parameters | Add-SituationPowerNote -Situation $Situation
}