psmux 3.3.4

Terminal multiplexer for Windows - tmux alternative for PowerShell and Windows Terminal
# test_e2e_latency.ps1 - End-to-end latency test for psmux
# Tests both WSL and pwsh, with tight sub-millisecond polling

param(
    [int]$CharCount = 60,
    [int]$InterKeyDelayMs = 80,
    [switch]$SkipWSL,
    [switch]$PwshOnly,
    [switch]$WSLOnly
)

$ErrorActionPreference = "Stop"
$psmuxExe = "$PSScriptRoot\..\target\release\psmux.exe"
if (-not (Test-Path $psmuxExe)) { $psmuxExe = "$PSScriptRoot\..\target\debug\psmux.exe" }

# Auto-detect WSL availability and skip WSL tests if not present
if (-not $SkipWSL -and -not $PwshOnly) {
    $wslExe = "$env:SystemRoot\System32\wsl.exe"
    if (-not (Test-Path $wslExe)) {
        Write-Host "[INFO] WSL not found — running pwsh-only tests" -ForegroundColor Yellow
        $PwshOnly = $true
    } else {
        $distroCheck = & $wslExe --list --quiet 2>&1 | Out-String
        if ($LASTEXITCODE -ne 0 -or $distroCheck.Trim().Length -eq 0) {
            Write-Host "[INFO] No WSL distro installed — running pwsh-only tests" -ForegroundColor Yellow
            $PwshOnly = $true
        }
    }
}

function Get-LayoutHash {
    param([string]$json)
    $idx = $json.IndexOf('"layout":')
    if ($idx -lt 0) { return $json.GetHashCode() }
    $start = $json.IndexOf('{', $idx)
    if ($start -lt 0) { return $json.GetHashCode() }
    $depth = 0
    for ($p = $start; $p -lt $json.Length; $p++) {
        $c = $json[$p]
        if ($c -eq '{') { $depth++ }
        elseif ($c -eq '}') { $depth--; if ($depth -eq 0) { return $json.Substring($start, $p - $start + 1).GetHashCode() } }
    }
    return $json.GetHashCode()
}

function Run-LatencyTest {
    param(
        [string]$Label,
        [bool]$UseWSL,
        [int]$Chars,
        [int]$InterDelay
    )
    
    $sessionName = "lattest_$(Get-Random)"
    
    Write-Host ""
    Write-Host "=== $Label ===" -ForegroundColor Cyan
    Write-Host "  Chars: $Chars, Inter-key delay: ${InterDelay}ms"
    
    # Start server
    if ($UseWSL) {
        $proc = Start-Process -FilePath $psmuxExe -ArgumentList "new-session", "-d", "-s", $sessionName, "wsl" -PassThru -WindowStyle Hidden
    } else {
        $proc = Start-Process -FilePath $psmuxExe -ArgumentList "new-session", "-d", "-s", $sessionName -PassThru -WindowStyle Hidden
    }
    
    $homeDir = $env:USERPROFILE
    $pf = "$homeDir\.psmux\${sessionName}.port"
    $kf = "$homeDir\.psmux\${sessionName}.key"
    
    $t = 0
    while ((-not (Test-Path $pf)) -or (-not (Test-Path $kf))) {
        Start-Sleep -Milliseconds 200; $t += 0.2
        if ($t -ge 10) { Write-Host "ERROR: Server start timeout" -ForegroundColor Red; $proc.Kill(); return $null }
    }
    
    $port = [int](Get-Content $pf -Raw).Trim()
    $key = (Get-Content $kf -Raw).Trim()
    
    $tcp = New-Object System.Net.Sockets.TcpClient
    $tcp.NoDelay = $true
    $tcp.Connect("127.0.0.1", $port)
    $ns = $tcp.GetStream()
    $ns.ReadTimeout = 10000
    $wr = New-Object System.IO.StreamWriter($ns)
    $wr.AutoFlush = $false
    $rd = New-Object System.IO.StreamReader($ns)
    
    $wr.WriteLine("AUTH $key"); $wr.Flush()
    $auth = $rd.ReadLine()
    if ($auth -ne "OK") { Write-Host "Auth failed" -ForegroundColor Red; $tcp.Close(); $proc.Kill(); return $null }
    
    $wr.WriteLine("PERSISTENT"); $wr.Flush()
    Start-Sleep -Milliseconds 100
    
    # Set size
    $wr.WriteLine("client-size 120 30"); $wr.Flush()
    Start-Sleep -Milliseconds 500
    
    # Wait for shell
    for ($i = 0; $i -lt 50; $i++) {
        $wr.WriteLine("dump-state"); $wr.Flush()
        $r = $rd.ReadLine()
        if ($r -and $r -ne "NC" -and $r.Length -gt 100) { break }
        Start-Sleep -Milliseconds 200
    }
    Start-Sleep -Milliseconds 1000
    
    # Clear screen  
    foreach ($c in [char[]]"clear") {
        $wr.WriteLine("send-text ""$c"""); $wr.Flush()
        Start-Sleep -Milliseconds 30
    }
    $wr.WriteLine("send-key enter"); $wr.Flush()
    Start-Sleep -Milliseconds 500
    
    for ($i = 0; $i -lt 10; $i++) {
        $wr.WriteLine("dump-state"); $wr.Flush()
        $r = $rd.ReadLine()
        if ($r -eq "NC") { break }
        Start-Sleep -Milliseconds 50
    }
    
    # Get baseline
    $wr.WriteLine("dump-state"); $wr.Flush()
    $baseline = $rd.ReadLine()
    if ($baseline -eq "NC") { Start-Sleep -Milliseconds 100; $wr.WriteLine("dump-state"); $wr.Flush(); $baseline = $rd.ReadLine() }
    $prevHash = Get-LayoutHash $baseline
    
    Write-Host "  Ready. JSON: $($baseline.Length) bytes"
    
    # Type characters with tight polling (NO sleep between polls)
    $charStr = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz"
    $latencies = [System.Collections.ArrayList]::new()
    $pollCountList = [System.Collections.ArrayList]::new()
    
    # Use high-resolution timer
    $freq = [System.Diagnostics.Stopwatch]::Frequency
    
    for ($i = 0; $i -lt $Chars; $i++) {
        $ch = $charStr[$i % $charStr.Length]
        
        $startTick = [System.Diagnostics.Stopwatch]::GetTimestamp()
        
        # Send char
        $wr.WriteLine("send-text ""$ch"""); $wr.Flush()
        
        # Immediately start polling dump-state (no sleep between polls)
        $polls = 0
        $found = $false
        $maxTicks = $freq / 2  # 500ms timeout
        
        while (([System.Diagnostics.Stopwatch]::GetTimestamp() - $startTick) -lt $maxTicks) {
            $wr.WriteLine("dump-state"); $wr.Flush()
            $resp = $rd.ReadLine()
            $polls++
            
            if ($resp -ne "NC") {
                $h = Get-LayoutHash $resp
                if ($h -ne $prevHash) {
                    $found = $true
                    $prevHash = $h
                    break
                }
            }
        }
        
        $endTick = [System.Diagnostics.Stopwatch]::GetTimestamp()
        $elapsedMs = [math]::Round(($endTick - $startTick) * 1000.0 / $freq, 1)
        
        [void]$latencies.Add($elapsedMs)
        [void]$pollCountList.Add($polls)
        
        if (-not $found) {
            Write-Host "  WARN: no echo for '$ch' (idx $i)" -ForegroundColor Red
        }
        
        # Progress
        if (($i + 1) % 10 -eq 0) {
            $s = [math]::Max(0, $i - 9)
            $slice = $latencies[$s..$i]
            $avg = ($slice | Measure-Object -Average).Average
            $max = ($slice | Measure-Object -Maximum).Maximum
            $pa = ($pollCountList[$s..$i] | Measure-Object -Average).Average
            Write-Host ("  [{0,3}-{1,3}] avg={2,6:F1}ms  max={3,6:F1}ms  polls={4,5:F1}" -f ($s+1), ($i+1), $avg, $max, $pa)
        }
        
        if ($InterDelay -gt 0 -and $i -lt ($Chars - 1)) {
            Start-Sleep -Milliseconds $InterDelay
        }
    }
    
    # Analysis
    $stats = $latencies | Measure-Object -Average -Minimum -Maximum
    $sorted = [double[]]($latencies | Sort-Object)
    $p50 = $sorted[[math]::Floor($sorted.Count * 0.5)]
    $p90 = $sorted[[math]::Floor($sorted.Count * 0.9)]
    $p99 = $sorted[[math]::Min($sorted.Count - 1, [math]::Floor($sorted.Count * 0.99))]
    
    $q1e = [math]::Floor($Chars/4) - 1
    $q4s = [math]::Floor($Chars*3/4)
    $q1a = ($latencies[0..$q1e] | Measure-Object -Average).Average
    $q4a = ($latencies[$q4s..($Chars-1)] | Measure-Object -Average).Average
    $deg = if ($q1a -gt 0) { (($q4a - $q1a) / $q1a) * 100 } else { 0 }
    
    Write-Host ""
    Write-Host ("  Avg={0:F1}ms  P50={1:F1}ms  P90={2:F1}ms  P99={3:F1}ms  Min={4:F1}ms  Max={5:F1}ms" -f `
        $stats.Average, $p50, $p90, $p99, $stats.Minimum, $stats.Maximum) -ForegroundColor White
    Write-Host ("  Q1={0:F1}ms  Q4={1:F1}ms  Degrade={2:+0.0;-0.0}%" -f $q1a, $q4a, $deg) -ForegroundColor White
    
    # Distribution
    $ranges = @(@{N="0-5ms";Lo=0;Hi=5}, @{N="5-10ms";Lo=5;Hi=10}, @{N="10-20ms";Lo=10;Hi=20}, @{N="20-40ms";Lo=20;Hi=40}, @{N="40-60ms";Lo=40;Hi=60}, @{N="60ms+";Lo=60;Hi=99999})
    foreach ($r in $ranges) {
        $cnt = @($latencies | Where-Object { $_ -ge $r.Lo -and $_ -lt $r.Hi }).Count
        if ($cnt -gt 0) {
            $pct = [math]::Round(($cnt / $Chars) * 100)
            Write-Host ("    {0,8}: {1,3} ({2,3}%)" -f $r.N, $cnt, $pct)
        }
    }
    
    Write-Host "  Raw: $($latencies -join ', ')" -ForegroundColor DarkGray
    
    # Cleanup
    try { $tcp.Close() } catch {}
    try { & $psmuxExe kill-server -t $sessionName 2>$null } catch {}
    Start-Sleep -Milliseconds 300
    if (-not $proc.HasExited) { try { $proc.Kill() } catch {} }
    Remove-Item $pf -ErrorAction SilentlyContinue
    Remove-Item $kf -ErrorAction SilentlyContinue
    
    return @{
        Label = $Label
        Avg = $stats.Average
        P50 = $p50
        P90 = $p90
        P99 = $p99
        Min = $stats.Minimum
        Max = $stats.Maximum
        Q1 = $q1a
        Q4 = $q4a
        Degradation = $deg
    }
}

# ── Run tests ──
$results = @()

if (-not $PwshOnly) {
    $r = Run-LatencyTest -Label "WSL (80ms between keys)" -UseWSL $true -Chars $CharCount -InterDelay $InterKeyDelayMs
    if ($r) { $results += $r }
    
    $r = Run-LatencyTest -Label "WSL (20ms burst typing)" -UseWSL $true -Chars $CharCount -InterDelay 20
    if ($r) { $results += $r }
}

if (-not $WSLOnly) {
    $r = Run-LatencyTest -Label "pwsh (80ms between keys)" -UseWSL $false -Chars $CharCount -InterDelay $InterKeyDelayMs
    if ($r) { $results += $r }
    
    $r = Run-LatencyTest -Label "pwsh (20ms burst typing)" -UseWSL $false -Chars $CharCount -InterDelay 20
    if ($r) { $results += $r }
}

# Summary
Write-Host ""
Write-Host "=== SUMMARY ===" -ForegroundColor Cyan
Write-Host ("{0,-30} {1,8} {2,8} {3,8} {4,8} {5,10}" -f "Test", "Avg", "P50", "P90", "Max", "Degrade") -ForegroundColor Yellow
foreach ($r in $results) {
    Write-Host ("{0,-30} {1,7:F1}ms {2,7:F1}ms {3,7:F1}ms {4,7:F1}ms {5,9:+0.0;-0.0}%" -f `
        $r.Label, $r.Avg, $r.P50, $r.P90, $r.Max, $r.Degradation)
}
Write-Host ""