|
| 1 | +<# |
| 2 | +get-fsharp-errors.ps1 - cross-platform client for the fsharp-diag-server. |
| 3 | +Requires pwsh 7+ (AF_UNIX socket support on Windows 10 1803+). |
| 4 | +#> |
| 5 | + |
| 6 | +[CmdletBinding(PositionalBinding = $false)] |
| 7 | +param( |
| 8 | + [switch]$ParseOnly, |
| 9 | + [switch]$CheckProject, |
| 10 | + [switch]$Ping, |
| 11 | + [switch]$Shutdown, |
| 12 | + [switch]$FindRefs, |
| 13 | + [switch]$TypeHints, |
| 14 | + [Parameter(ValueFromRemainingArguments = $true)] |
| 15 | + [string[]]$Rest |
| 16 | +) |
| 17 | + |
| 18 | +$ErrorActionPreference = 'Stop' |
| 19 | +Set-StrictMode -Version Latest |
| 20 | + |
| 21 | +$ScriptDir = Split-Path -Parent $PSCommandPath |
| 22 | +$ServerProject = (Resolve-Path (Join-Path $ScriptDir '..' 'server')).Path |
| 23 | +$SockDir = Join-Path $HOME '.fsharp-diag' |
| 24 | +$StartTimeoutSec = 180 # > documented 70s cold start, covers slow nuget restore |
| 25 | +$ConnectTimeoutMs = 5000 |
| 26 | +$IoTimeoutMs = 600000 # 10 min for checkProject; safe upper bound |
| 27 | + |
| 28 | +function Show-Usage { |
| 29 | + @" |
| 30 | +Usage: |
| 31 | + get-fsharp-errors.ps1 [-ParseOnly] <file.fs> |
| 32 | + get-fsharp-errors.ps1 -FindRefs <file.fs> <line> <col> |
| 33 | + get-fsharp-errors.ps1 -TypeHints <file.fs> <startLine> <endLine> |
| 34 | + get-fsharp-errors.ps1 -CheckProject | -Ping | -Shutdown |
| 35 | +"@ | Out-Host |
| 36 | +} |
| 37 | + |
| 38 | +function Get-RepoRoot { |
| 39 | + # Server normalizes via Path.GetFullPath; client must do the same before hashing. |
| 40 | + $raw = try { (& git rev-parse --show-toplevel 2>$null) } catch { $null } |
| 41 | + if ([string]::IsNullOrWhiteSpace($raw)) { $raw = (Get-Location).Path } |
| 42 | + [System.IO.Path]::GetFullPath($raw.Trim()) |
| 43 | +} |
| 44 | + |
| 45 | +function Get-Hash16([string]$s) { |
| 46 | + # Mirrors Server.fs deriveHash exactly. |
| 47 | + $bytes = [System.Text.Encoding]::UTF8.GetBytes($s) |
| 48 | + [System.Convert]::ToHexString( |
| 49 | + [System.Security.Cryptography.SHA256]::HashData($bytes) |
| 50 | + ).Substring(0, 16).ToLowerInvariant() |
| 51 | +} |
| 52 | + |
| 53 | +function Get-SocketPath([string]$root) { Join-Path $SockDir ((Get-Hash16 $root) + '.sock') } |
| 54 | +function Get-LogPath ([string]$root) { Join-Path $SockDir ((Get-Hash16 $root) + '.log') } |
| 55 | +function Get-LockPath ([string]$root) { Join-Path $SockDir ((Get-Hash16 $root) + '.startup.lock') } |
| 56 | + |
| 57 | +function Resolve-AbsFile([string]$p) { |
| 58 | + # Lexical resolution - missing files reach the server's JSON not-found handler. |
| 59 | + if ([System.IO.Path]::IsPathRooted($p)) { |
| 60 | + [System.IO.Path]::GetFullPath($p) |
| 61 | + } else { |
| 62 | + [System.IO.Path]::GetFullPath((Join-Path (Get-Location).Path $p)) |
| 63 | + } |
| 64 | +} |
| 65 | + |
| 66 | +function New-DiagSocket { |
| 67 | + New-Object System.Net.Sockets.Socket( |
| 68 | + [System.Net.Sockets.AddressFamily]::Unix, |
| 69 | + [System.Net.Sockets.SocketType]::Stream, |
| 70 | + [System.Net.Sockets.ProtocolType]::Unspecified) |
| 71 | +} |
| 72 | + |
| 73 | +function Send-Request([string]$sock, [hashtable]$payload, [int]$timeoutMs = $IoTimeoutMs) { |
| 74 | + $json = ($payload | ConvertTo-Json -Compress -Depth 4) + "`n" |
| 75 | + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) |
| 76 | + $client = New-DiagSocket |
| 77 | + try { |
| 78 | + $client.SendTimeout = $timeoutMs |
| 79 | + $client.ReceiveTimeout = $timeoutMs |
| 80 | + $task = $client.ConnectAsync((New-Object System.Net.Sockets.UnixDomainSocketEndPoint($sock))) |
| 81 | + if (-not $task.Wait($ConnectTimeoutMs)) { throw "connect timed out after $ConnectTimeoutMs ms ($sock)" } |
| 82 | + [void]$client.Send($bytes) |
| 83 | + $client.Shutdown([System.Net.Sockets.SocketShutdown]::Send) |
| 84 | + # Stream UTF-8 across recv boundaries so multibyte chars don't fragment. |
| 85 | + $buf = New-Object byte[] 65536 |
| 86 | + $decoder = [System.Text.Encoding]::UTF8.GetDecoder() |
| 87 | + $chars = New-Object char[] $buf.Length |
| 88 | + $sb = [System.Text.StringBuilder]::new() |
| 89 | + while (($n = $client.Receive($buf)) -gt 0) { |
| 90 | + $c = $decoder.GetChars($buf, 0, $n, $chars, 0) |
| 91 | + [void]$sb.Append($chars, 0, $c) |
| 92 | + } |
| 93 | + $sb.ToString() |
| 94 | + } finally { $client.Dispose() } |
| 95 | +} |
| 96 | + |
| 97 | +function Test-ServerAlive([string]$sock) { |
| 98 | + if (-not (Test-Path $sock)) { return $false } |
| 99 | + try { (Send-Request $sock @{ command = 'ping' } 2000) -match '"ok"' } catch { $false } |
| 100 | +} |
| 101 | + |
| 102 | +function Start-DiagServer([string]$root, [string]$sock) { |
| 103 | + if (Test-ServerAlive $sock) { return } |
| 104 | + New-Item -ItemType Directory -Force -Path $SockDir | Out-Null |
| 105 | + $lockPath = Get-LockPath $root |
| 106 | + $lock = $null |
| 107 | + try { |
| 108 | + # Serialize startup so racing clients don't spawn duplicate servers. |
| 109 | + $lock = [System.IO.File]::Open($lockPath, [System.IO.FileMode]::OpenOrCreate, |
| 110 | + [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) |
| 111 | + # Re-check after acquiring the lock - peer may have started a server while we waited. |
| 112 | + if (Test-ServerAlive $sock) { return } |
| 113 | + if (Test-Path $sock) { Remove-Item -Force $sock } |
| 114 | + $log = Get-LogPath $root |
| 115 | + $psi = New-Object System.Diagnostics.ProcessStartInfo |
| 116 | + $psi.FileName = 'dotnet' |
| 117 | + # ArgumentList (Collection<string>) handles per-platform quoting (incl. spaces in paths). |
| 118 | + foreach ($a in @('run','-c','Release','--project',$ServerProject,'--','--repo-root',$root)) { |
| 119 | + [void]$psi.ArgumentList.Add($a) |
| 120 | + } |
| 121 | + $psi.RedirectStandardOutput = $true |
| 122 | + $psi.RedirectStandardError = $true |
| 123 | + $psi.UseShellExecute = $false |
| 124 | + $psi.CreateNoWindow = $true |
| 125 | + $proc = [System.Diagnostics.Process]::Start($psi) |
| 126 | + # Drain to log file so the child's pipes don't fill and block. |
| 127 | + $proc.StandardOutput.BaseStream.CopyToAsync([System.IO.File]::Create($log)) | Out-Null |
| 128 | + $proc.StandardError.BaseStream.CopyToAsync( |
| 129 | + [System.IO.File]::Create("$log.err")) | Out-Null |
| 130 | + # Poll for a LIVE server (file existence is insufficient - server may be mid-bind). |
| 131 | + $sw = [System.Diagnostics.Stopwatch]::StartNew() |
| 132 | + while ($sw.Elapsed.TotalSeconds -lt $StartTimeoutSec) { |
| 133 | + if (Test-ServerAlive $sock) { return } |
| 134 | + Start-Sleep -Milliseconds 500 |
| 135 | + } |
| 136 | + throw "Server failed to start within ${StartTimeoutSec}s. Check log: $log" |
| 137 | + } finally { |
| 138 | + if ($lock) { $lock.Dispose(); Remove-Item -Force $lockPath -ErrorAction SilentlyContinue } |
| 139 | + } |
| 140 | +} |
| 141 | + |
| 142 | +function Assert-RequiredArg([int]$needed, [string]$cmd) { |
| 143 | + if (-not $Rest -or $Rest.Count -lt $needed) { |
| 144 | + Write-Error "$cmd requires $needed positional argument(s): see -? for usage." -ErrorAction Continue |
| 145 | + Show-Usage |
| 146 | + exit 1 |
| 147 | + } |
| 148 | +} |
| 149 | + |
| 150 | +function ConvertTo-Int32Arg([string]$s, [string]$name) { |
| 151 | + $out = 0 |
| 152 | + if (-not [int]::TryParse($s, [ref]$out)) { |
| 153 | + Write-Error "$name must be an integer, got '$s'" -ErrorAction Continue |
| 154 | + Show-Usage |
| 155 | + exit 1 |
| 156 | + } |
| 157 | + $out |
| 158 | +} |
| 159 | + |
| 160 | +# --- main --- |
| 161 | + |
| 162 | +$root = Get-RepoRoot |
| 163 | +$sock = Get-SocketPath $root |
| 164 | + |
| 165 | +# Validate args BEFORE spawning a 70s+ server. |
| 166 | +$payload = |
| 167 | + if ($Ping) { @{ command = 'ping' } } |
| 168 | + elseif ($Shutdown) { @{ command = 'shutdown' } } |
| 169 | + elseif ($CheckProject) { @{ command = 'checkProject' } } |
| 170 | + elseif ($ParseOnly) { Assert-RequiredArg 1 '-ParseOnly'; @{ command = 'parseOnly'; file = (Resolve-AbsFile $Rest[0]) } } |
| 171 | + elseif ($FindRefs) { Assert-RequiredArg 3 '-FindRefs'; @{ command = 'findRefs'; file = (Resolve-AbsFile $Rest[0]); line = (ConvertTo-Int32Arg $Rest[1] 'line'); col = (ConvertTo-Int32Arg $Rest[2] 'col') } } |
| 172 | + elseif ($TypeHints) { Assert-RequiredArg 3 '-TypeHints'; @{ command = 'typeHints'; file = (Resolve-AbsFile $Rest[0]); startLine = (ConvertTo-Int32Arg $Rest[1] 'startLine'); endLine = (ConvertTo-Int32Arg $Rest[2] 'endLine') } } |
| 173 | + elseif ($Rest -and $Rest.Count -ge 1) { @{ command = 'check'; file = (Resolve-AbsFile $Rest[0]) } } |
| 174 | + else { Show-Usage; exit 1 } |
| 175 | + |
| 176 | +# Skip server start for -Shutdown (would be pointless) and ensure friendly error if absent. |
| 177 | +if (-not $Shutdown) { Start-DiagServer $root $sock } |
| 178 | + |
| 179 | +try { |
| 180 | + Send-Request $sock $payload |
| 181 | +} catch { |
| 182 | + if ($Shutdown) { |
| 183 | + Write-Output '{ "status":"not_running" }' |
| 184 | + } else { |
| 185 | + Write-Error "Cannot reach diagnostics server at $sock`: $($_.Exception.Message)" |
| 186 | + exit 1 |
| 187 | + } |
| 188 | +} |
0 commit comments