Private/Test-WinClatWsManPort.ps1

Function Test-WinClatWsManPort {

    <#

    .SYNOPSIS
        Checks which computers answer on the WinRM HTTP port before Invoke-Command is used.

    .DESCRIPTION
        Starts one TCP connection to the WinRM port of every computer at the same time with System.Net.Sockets.TcpClient and waits up to the timeout for all of them (DESIGN.md, section 4.3). A computer that does not accept the connection in time is reported, so Invoke-WinClatRemote can return it as Unreachable at once instead of waiting for a WinRM connection timeout. Ping is not used, because ICMP is often blocked where WinRM is allowed.

        Returns a hashtable of the computers that failed, keyed by name, with a short message as the value:
          - The name cannot be resolved in DNS.
          - The connection was refused, so the computer is up but WinRM is not listening.
          - No answer within the timeout: the computer may be offline, WinRM may not be enabled, or a firewall may drop the connection. Windows retries a refused connection for about two seconds, so a computer with WinRM off usually ends here rather than as refused.
        Computers that answered are not in the hashtable. A computer that answers can still fail later in WinRM, for example on authentication; Invoke-WinClatRemote reports that as before.

        Failures are read from the task state rather than caught as exceptions, because an exception caught inside an advanced function is also added to the caller's -ErrorVariable.

    .PARAMETER ComputerName
        The remote computers to check.

    .PARAMETER Port
        The TCP port. The default is 5985, the WinRM HTTP port.

    .PARAMETER TimeoutMilliseconds
        How long to wait for all connections. The default is 2000.

    .INPUTS
        None. This function does not accept pipeline input.

    .OUTPUTS
        System.Collections.Hashtable.

    .EXAMPLE
        Test-WinClatWsManPort -ComputerName 'CLIENT4', 'CLIENT9'

        Returns @{ CLIENT9 = 'WinRM (TCP 5985) did not respond within 2 seconds. ...' } when CLIENT9 is offline.

    .LINK
        https://learn.microsoft.com/dotnet/api/system.net.sockets.tcpclient.connectasync

    .NOTES
        Version: 1.0.0
        Creation Date: October 28, 2026
        Last Updated: October 28, 2026
        Author: Richard Hicks
        Organization: Richard M. Hicks Consulting, Inc.
        Contact: rich@richardhicks.com
        Website: https://www.richardhicks.com/

    #>


    [CmdletBinding()]
    [OutputType([hashtable])]

    Param (

        [Parameter(Mandatory)]
        [AllowEmptyCollection()]
        [string[]]$ComputerName,

        [ValidateRange(1, 65535)]
        [int]$Port = 5985,

        [ValidateRange(1, 600000)]
        [int]$TimeoutMilliseconds = 2000

    )

    $Failed = @{}
    $Attempts = New-Object -TypeName 'System.Collections.Generic.List[object]'
    $Seconds = [Math]::Round($TimeoutMilliseconds / 1000, 1)

    ForEach ($Name in $ComputerName) {

        $Client = New-Object -TypeName System.Net.Sockets.TcpClient -ArgumentList ([System.Net.Sockets.AddressFamily]::InterNetworkV6)

        # Accept both IPv4 and IPv6 addresses for the name.
        $Client.Client.DualMode = $true
        $Attempts.Add([PSCustomObject]@{ Name = $Name; Client = $Client; Task = $Client.ConnectAsync($Name, $Port) })

    }

    # Poll instead of Task.WaitAll, which throws when any connection fails.
    $Watch = [System.Diagnostics.Stopwatch]::StartNew()

    While ($Watch.ElapsedMilliseconds -lt $TimeoutMilliseconds -and @($Attempts | Where-Object -FilterScript { -not $_.Task.IsCompleted }).Count -gt 0) {

        Start-Sleep -Milliseconds 25

    }

    ForEach ($Attempt in $Attempts) {

        $Task = $Attempt.Task

        If (-not $Task.IsCompleted) {

            # Windows retries a refused connection for about two seconds, so WinRM not listening usually ends here too.
            $Failed[$Attempt.Name] = "WinRM (TCP $Port) did not respond within $Seconds seconds. The computer may be offline, remote management may not be enabled, or a firewall may block it."

        }

        ElseIf ($Task.IsFaulted -or $Task.IsCanceled -or -not $Attempt.Client.Connected) {

            $Code = $null

            If ($Task.Exception) {

                $Inner = $Task.Exception.GetBaseException()

                If ($Inner -is [System.Net.Sockets.SocketException]) {

                    $Code = $Inner.SocketErrorCode

                }

            }

            $Message = "WinRM (TCP $Port) could not be reached ($Code). The computer may be offline, or remote management is not enabled."

            If ($Code -eq [System.Net.Sockets.SocketError]::HostNotFound -or $Code -eq [System.Net.Sockets.SocketError]::NoData -or $Code -eq [System.Net.Sockets.SocketError]::TryAgain) {

                $Message = 'The name cannot be resolved in DNS, so the computer was not contacted.'

            }

            ElseIf ($Code -eq [System.Net.Sockets.SocketError]::ConnectionRefused) {

                $Message = "The computer refused a connection on TCP $Port, so WinRM is not listening. Remote management is not enabled."

            }

            ElseIf (-not $Code) {

                $Message = "WinRM (TCP $Port) could not be reached. The computer may be offline, or remote management is not enabled."

            }

            $Failed[$Attempt.Name] = $Message

        }

        # Closing a pending connection ends it; a faulted task's exception is observed above, or ignored by the runtime.
        $Attempt.Client.Close()

    }

    $Failed

}