Public/New-HDTBuildProgress.ps1

function New-HDTBuildProgress {
    <#
        .SYNOPSIS
            The channel Update-HDTBootImage reports its seventeen steps on.
 
        .DESCRIPTION
            THE BUILD WAS SILENT, AND THAT IS WHAT THIS FIXES. Two and a half
            minutes between a ShouldProcess and a result object, with nothing in
            between: on a console that is a prompt which has stopped answering,
            and in a window it is worse - the window greys out, an administrator
            reasonably concludes it has hung and kills it, and a killed build
            strands a mounted image that needs dism /cleanup-wim before anything
            can build again.
 
            IT IS A SERVICE, NOT Write-Progress. The build has to be able to run
            in a background runspace with a window draining its reports on the
            dispatcher, and a cmdlet writing to its own progress stream cannot be
            read from another thread. An injected sink is also what CLAUDE.md
            rule 5 asks for, and it makes the reporting assertable under Pester
            with no window and no ADK.
 
            THE DEFAULT RECORDS NOTHING. Update-HDTBootImage reports on every
            build whether or not anybody is watching, so the do-nothing path is
            the common one and it must cost nothing: with no -Queue, Report is a
            method that returns.
 
            DRAINED MEANS TAKEN. A watcher ticks a few times a second and
            appends whatever it drains; a Drain that left the reports behind
            would repaint the whole build on every tick.
 
            THE QUEUE IS THE CALLER'S, AND IT MUST BE SYNCHRONIZED. Two threads
            cross it - the runspace reporting and the dispatcher draining - and
            an unsynchronised Queue corrupts or throws under that. Taking it as
            a parameter rather than making one here is what lets the window hand
            the same queue to a sink it passes into another runspace.
 
        .PARAMETER Queue
            The synchronized queue to record into. Omitted, nothing is recorded.
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject with Report, Complete
            and Drain.
 
        .EXAMPLE
            $queue = [System.Collections.Queue]::Synchronized([System.Collections.Queue]::new())
            $progress = New-HDTBuildProgress -Queue $queue
            Update-HDTBootImage -WorkspaceRoot 'C:\HDTLab\Share' -Progress $progress
 
        .EXAMPLE
            foreach ($report in $progress.Drain()) {
                '{0}/{1} {2}' -f $report.Step, $report.Total, $report.Title
            }
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Creates an in-memory service object; it changes no state.')]
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Position = 0)]
        [AllowNull()]
        [object] $Queue = $null
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $service = [pscustomobject] @{
        Queue = $Queue
    }

    $service | Add-Member -MemberType ScriptMethod -Name Report -Value {
        param([int] $Step, [int] $Total, [string] $Title, [string] $Detail)

        if ($null -eq $this.Queue) { return }

        $this.Queue.Enqueue([pscustomobject] @{
                Step       = $Step
                Total      = $Total
                Title      = $Title
                Detail     = $Detail
                IsComplete = $false
                Succeeded  = $false
            })
    }

    # THE END IS A REPORT LIKE ANY OTHER, so a watcher reads one stream and
    # never has to poll a second thing to find out whether the build is still
    # going. A window that simply stopped receiving would have to guess between
    # "finished", "slow" and "died".
    $service | Add-Member -MemberType ScriptMethod -Name Complete -Value {
        param([bool] $Succeeded, [string] $Detail)

        if ($null -eq $this.Queue) { return }

        $this.Queue.Enqueue([pscustomobject] @{
                Step       = 0
                Total      = 0
                Title      = 'finished'
                Detail     = $Detail
                IsComplete = $true
                Succeeded  = $Succeeded
            })
    }

    $service | Add-Member -MemberType ScriptMethod -Name Drain -Value {

        if ($null -eq $this.Queue) { return , ([object[]] @()) }

        $taken = New-Object -TypeName System.Collections.ArrayList

        # Count is read once per iteration rather than cached: the other thread
        # is still enqueueing, and draining "the count it had when we started"
        # is a race that loses reports at the end of a build.
        while ($this.Queue.Count -gt 0) {
            [void] $taken.Add($this.Queue.Dequeue())
        }

        # The unary comma is mandatory: a ScriptMethod collapses a
        # single-element array to a scalar without it.
        return , ([object[]] @($taken))
    }

    return $service
}