diff --git a/.github/skills/fsharp-diagnostics/SKILL.md b/.github/skills/fsharp-diagnostics/SKILL.md index 846303abe6d..51afcdb4b3b 100644 --- a/.github/skills/fsharp-diagnostics/SKILL.md +++ b/.github/skills/fsharp-diagnostics/SKILL.md @@ -1,48 +1,56 @@ --- name: fsharp-diagnostics -description: "Always invoke after editing .fs files. Provides fast parse/typecheck feedback without a full dotnet build. Prefer this over dotnet build for iterative changes. Also finds symbol references and inferred type hints." +description: Always invoke after editing `.fs` files under `src/Compiler/`. Fast parse/typecheck without `dotnet build`, plus symbol references and inferred type hints. Use whenever the user asks about F# errors, compile errors, type inference, finding usages, or renaming a symbol in the compiler tree. --- # F# Diagnostics -**Scope:** `src/Compiler/` files only (`FSharp.Compiler.Service.fsproj`, Release, net10.0). +**Scope:** `src/Compiler/` files only. -## Setup (run once per shell session) +## Setup (once per session) -```bash -GetErrors() { "$(git rev-parse --show-toplevel)/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh" "$@"; } +Requires pwsh 7+ (`brew install powershell` / `winget install Microsoft.PowerShell` / `apt install powershell`). + +```pwsh +function GetErrors { & "$(git rev-parse --show-toplevel)/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1" @args } ``` +From bash/zsh without a function: `pwsh -File /.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1 `. + ## Parse first, typecheck second -```bash -GetErrors --parse-only src/Compiler/Checking/CheckBasics.fs -``` -If errors → fix syntax. Do NOT typecheck until parse is clean. -```bash -GetErrors src/Compiler/Checking/CheckBasics.fs +```pwsh +GetErrors -ParseOnly src/Compiler/Checking/CheckBasics.fs # syntax only +GetErrors src/Compiler/Checking/CheckBasics.fs # full typecheck ``` +Fix all parse errors before typechecking; type errors on top of bad syntax are noise. -## Find references for a single symbol (line 1-based, col 0-based) +## Symbol references (line 1-based, col 0-based) -Before renaming or to understand call sites: -```bash -GetErrors --find-refs src/Compiler/Checking/CheckBasics.fs 30 5 +```pwsh +GetErrors -FindRefs src/Compiler/Checking/CheckBasics.fs 30 5 ``` +Use before any rename. -## Type hints for a range selection (begin and end line numbers, 1-based) +## Type hints (line range, 1-based) -To see inferred types as inline `// (name: Type)` comments: -```bash -GetErrors --type-hints src/Compiler/TypedTree/TypedTreeOps.fs 1028 1032 +Returns the range with inferred types as inline `// (name: Type)` comments: +```pwsh +GetErrors -TypeHints src/Compiler/TypedTree/TypedTreeOps.Transforms.fs 100 120 ``` ## Other -```bash -GetErrors --check-project # typecheck entire project -GetErrors --ping -GetErrors --shutdown +```pwsh +GetErrors -CheckProject # typecheck entire project +GetErrors -Ping # liveness check, no side effects +GetErrors -Shutdown ``` -First call starts server (~70s cold start, set initial_wait=600). Auto-shuts down after 4h idle. ~3 GB RAM. +## Timing + +- First real call after a fresh clone: server build + in-memory warmup, 5–15 min → `initial_wait=1200`. +- After warmup: real commands answer in seconds → `initial_wait=180`. +- `-Ping` / `-Shutdown`: sub-second; never trigger build or warmup. + +Auto-shuts down after 4h idle; ~3 GB RAM while running. diff --git a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1 b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1 new file mode 100644 index 00000000000..b0dc87f38b4 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1 @@ -0,0 +1,233 @@ +<# +get-fsharp-errors.ps1 - cross-platform client for the fsharp-diag-server. +Requires pwsh 7+ (AF_UNIX socket support on Windows 10 1803+). +#> + +[CmdletBinding(PositionalBinding = $false)] +param( + [switch]$ParseOnly, + [switch]$CheckProject, + [switch]$Ping, + [switch]$Shutdown, + [switch]$FindRefs, + [switch]$TypeHints, + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$Rest +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$ScriptDir = Split-Path -Parent $PSCommandPath +$ServerProject = (Resolve-Path (Join-Path $ScriptDir '..' 'server')).Path +$SockDir = Join-Path $HOME '.fsharp-diag' +$StartTimeoutSec = 180 # > documented 70s cold start, covers slow nuget restore +$ConnectTimeoutMs = 5000 +$IoTimeoutMs = 1800000 # 30 min - covers cold-clone warmup (nuget restore + FCS in-memory typecheck of ~2M lines) + +function Show-Usage { + @" +Usage: + get-fsharp-errors.ps1 [-ParseOnly] + get-fsharp-errors.ps1 -FindRefs + get-fsharp-errors.ps1 -TypeHints + get-fsharp-errors.ps1 -CheckProject | -Ping | -Shutdown +"@ | Out-Host +} + +function Get-RepoRoot { + # Server normalizes via Path.GetFullPath; client must do the same before hashing. + $raw = try { (& git rev-parse --show-toplevel 2>$null) } catch { $null } + if ([string]::IsNullOrWhiteSpace($raw)) { $raw = (Get-Location).Path } + [System.IO.Path]::GetFullPath($raw.Trim()) +} + +function Get-Hash16([string]$s) { + # Mirrors Server.fs deriveHash exactly. + $bytes = [System.Text.Encoding]::UTF8.GetBytes($s) + [System.Convert]::ToHexString( + [System.Security.Cryptography.SHA256]::HashData($bytes) + ).Substring(0, 16).ToLowerInvariant() +} + +function Get-SocketPath([string]$root) { Join-Path $SockDir ((Get-Hash16 $root) + '.sock') } +function Get-LogPath ([string]$root) { Join-Path $SockDir ((Get-Hash16 $root) + '.log') } +function Get-LockPath ([string]$root) { Join-Path $SockDir ((Get-Hash16 $root) + '.startup.lock') } + +function Resolve-AbsFile([string]$p) { + # Lexical resolution - missing files reach the server's JSON not-found handler. + if ([System.IO.Path]::IsPathRooted($p)) { + [System.IO.Path]::GetFullPath($p) + } else { + [System.IO.Path]::GetFullPath((Join-Path (Get-Location).Path $p)) + } +} + +function New-DiagSocket { + New-Object System.Net.Sockets.Socket( + [System.Net.Sockets.AddressFamily]::Unix, + [System.Net.Sockets.SocketType]::Stream, + [System.Net.Sockets.ProtocolType]::Unspecified) +} + +function Send-Request([string]$sock, [hashtable]$payload, [int]$timeoutMs = $IoTimeoutMs) { + $json = ($payload | ConvertTo-Json -Compress -Depth 4) + "`n" + $bytes = [System.Text.Encoding]::UTF8.GetBytes($json) + $client = New-DiagSocket + try { + $client.SendTimeout = $timeoutMs + $client.ReceiveTimeout = $timeoutMs + $task = $client.ConnectAsync((New-Object System.Net.Sockets.UnixDomainSocketEndPoint($sock))) + if (-not $task.Wait($ConnectTimeoutMs)) { throw "connect timed out after $ConnectTimeoutMs ms ($sock)" } + [void]$client.Send($bytes) + $client.Shutdown([System.Net.Sockets.SocketShutdown]::Send) + # Stream UTF-8 across recv boundaries so multibyte chars don't fragment. + $buf = New-Object byte[] 65536 + $decoder = [System.Text.Encoding]::UTF8.GetDecoder() + $chars = New-Object char[] $buf.Length + $sb = [System.Text.StringBuilder]::new() + while (($n = $client.Receive($buf)) -gt 0) { + $c = $decoder.GetChars($buf, 0, $n, $chars, 0) + [void]$sb.Append($chars, 0, $c) + } + $sb.ToString() + } finally { $client.Dispose() } +} + +function Test-ServerAlive([string]$sock) { + if (-not (Test-Path $sock)) { return $false } + try { (Send-Request $sock @{ command = 'ping' } 2000) -match '"ok"' } catch { $false } +} + +function Get-ServerBinaryPath { + # Ask MSBuild for the configured output path (honors BaseOutputPath etc.). Project settings only - no build required. + $p = & dotnet msbuild $ServerProject /p:Configuration=Release -getProperty:TargetPath 2>$null + if ($LASTEXITCODE -eq 0 -and $p) { $p.Trim() } else { $null } +} + +function Find-ServerBinary { + $p = Get-ServerBinaryPath + if ($p -and (Test-Path $p)) { $p } else { $null } +} + +function Build-DiagServer { + # Visible foreground build so the agent sees nuget restore + compile progress on a cold clone (can be 10+ min). + Write-Host "[fsharp-diag] Building server (first call after clone can take 10+ min for nuget restore + FSharp.Compiler.Service build)..." -ForegroundColor Yellow + $build = Start-Process -FilePath 'dotnet' ` + -ArgumentList @('build','-c','Release', $ServerProject) ` + -NoNewWindow -Wait -PassThru + if ($build.ExitCode -ne 0) { + throw "Server build failed (dotnet build exit $($build.ExitCode))." + } + $dll = Find-ServerBinary + if (-not $dll) { throw "Build reported success but FSharpDiagServer.dll not found (MSBuild TargetPath: $(Get-ServerBinaryPath))." } + $dll +} + +function Start-DiagServer([string]$root, [string]$sock) { + if (Test-ServerAlive $sock) { return } + New-Item -ItemType Directory -Force -Path $SockDir | Out-Null + $lockPath = Get-LockPath $root + $lock = $null + try { + # Serialize startup so racing clients don't spawn duplicate servers. + $lock = [System.IO.File]::Open($lockPath, [System.IO.FileMode]::OpenOrCreate, + [System.IO.FileAccess]::Write, [System.IO.FileShare]::None) + # Re-check after acquiring the lock - peer may have started a server while we waited. + if (Test-ServerAlive $sock) { return } + if (Test-Path $sock) { Remove-Item -Force $sock } + + # Ensure server binary exists. Build is foreground + visible so the agent sees progress. + $dll = Find-ServerBinary + if (-not $dll) { $dll = Build-DiagServer } + + $log = Get-LogPath $root + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = 'dotnet' + # ArgumentList (Collection) handles per-platform quoting (incl. spaces in paths). + # Launch via prebuilt dll so startup is bound by server init (~70s), not by build (~minutes). + foreach ($a in @($dll, '--repo-root', $root)) { [void]$psi.ArgumentList.Add($a) } + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + $proc = [System.Diagnostics.Process]::Start($psi) + # Drain to log file so the child's pipes don't fill and block. + $proc.StandardOutput.BaseStream.CopyToAsync([System.IO.File]::Create($log)) | Out-Null + $proc.StandardError.BaseStream.CopyToAsync( + [System.IO.File]::Create("$log.err")) | Out-Null + # Poll for a LIVE server (file existence is insufficient - server may be mid-bind). + $sw = [System.Diagnostics.Stopwatch]::StartNew() + while ($sw.Elapsed.TotalSeconds -lt $StartTimeoutSec) { + if (Test-ServerAlive $sock) { break } + Start-Sleep -Milliseconds 500 + } + if (-not (Test-ServerAlive $sock)) { + throw "Server failed to bind socket within ${StartTimeoutSec}s. Check log: $log" + } + # Pre-warm the in-memory project so the agent's first real request doesn't hang. + # On a fresh clone this triggers the target project's nuget restore + FCS type-check (5-15 min). + Write-Host "[fsharp-diag] Server bound; warming up in-memory FSharp.Compiler.Service project (5-15 min on cold clone)..." -ForegroundColor Yellow + $resp = Send-Request $sock @{ command = 'warmup' } + if ($resp -notmatch '"warmed"') { + throw "Warmup failed: $resp" + } + Write-Host "[fsharp-diag] Warmup complete." -ForegroundColor Green + } finally { + if ($lock) { $lock.Dispose(); Remove-Item -Force $lockPath -ErrorAction SilentlyContinue } + } +} + +function Assert-RequiredArg([int]$needed, [string]$cmd) { + if (-not $Rest -or $Rest.Count -lt $needed) { + Write-Error "$cmd requires $needed positional argument(s): see -? for usage." -ErrorAction Continue + Show-Usage + exit 1 + } +} + +function ConvertTo-Int32Arg([string]$s, [string]$name) { + $out = 0 + if (-not [int]::TryParse($s, [ref]$out)) { + Write-Error "$name must be an integer, got '$s'" -ErrorAction Continue + Show-Usage + exit 1 + } + $out +} + +# --- main --- + +$root = Get-RepoRoot +$sock = Get-SocketPath $root + +# Validate args BEFORE spawning a 70s+ server. +$payload = + if ($Ping) { @{ command = 'ping' } } + elseif ($Shutdown) { @{ command = 'shutdown' } } + elseif ($CheckProject) { @{ command = 'checkProject' } } + elseif ($ParseOnly) { Assert-RequiredArg 1 '-ParseOnly'; @{ command = 'parseOnly'; file = (Resolve-AbsFile $Rest[0]) } } + 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') } } + 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') } } + elseif ($Rest -and $Rest.Count -ge 1) { @{ command = 'check'; file = (Resolve-AbsFile $Rest[0]) } } + else { Show-Usage; exit 1 } + +# Ping and Shutdown are liveness ops - never trigger a build or 10+ min warmup. +# Real commands always go through Start-DiagServer (build + spawn + warmup if needed). +if ($Ping -or $Shutdown) { + try { + Send-Request $sock $payload 2000 + } catch { + Write-Output '{ "status":"not_running" }' + } + exit 0 +} + +Start-DiagServer $root $sock + +try { + Send-Request $sock $payload +} catch { + Write-Error "Cannot reach diagnostics server at $sock`: $($_.Exception.Message)" + exit 1 +} diff --git a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh deleted file mode 100755 index 824c37f7628..00000000000 --- a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# get-fsharp-errors.sh — minimal passthrough client for fsharp-diag-server -# Usage: -# get-fsharp-errors.sh [--parse-only] -# get-fsharp-errors.sh --check-project -# get-fsharp-errors.sh --ping -# get-fsharp-errors.sh --shutdown - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SERVER_PROJECT="$(cd "$SCRIPT_DIR/../server" && pwd)" -SOCK_DIR="$HOME/.fsharp-diag" - -get_repo_root() { - git rev-parse --show-toplevel 2>/dev/null || pwd -} - -get_socket_path() { - local root="$1" - local hash - hash=$(printf '%s' "$root" | shasum -a 256 | cut -c1-16) - echo "$SOCK_DIR/${hash}.sock" -} - -ensure_server() { - local root="$1" - local sock="$2" - - # Check if socket exists and server responds to ping - if [ -S "$sock" ]; then - local pong - pong=$(printf '{"command":"ping"}\n' | nc -U "$sock" 2>/dev/null || true) - if echo "$pong" | grep -q '"ok"'; then - return 0 - fi - # Stale socket - rm -f "$sock" - fi - - # Start server - mkdir -p "$SOCK_DIR" - local log_hash - log_hash=$(printf '%s' "$root" | shasum -a 256 | cut -c1-16) - local log_file="$SOCK_DIR/${log_hash}.log" - - nohup dotnet run -c Release --project "$SERVER_PROJECT" -- --repo-root "$root" > "$log_file" 2>&1 & - - # Wait for socket to appear (max 60s) - local waited=0 - while [ ! -S "$sock" ] && [ $waited -lt 60 ]; do - sleep 1 - waited=$((waited + 1)) - done - - if [ ! -S "$sock" ]; then - echo '{"error":"Server failed to start within 60s. Check log: '"$log_file"'"}' >&2 - exit 1 - fi -} - -send_request() { - local sock="$1" - local request="$2" - printf '%s\n' "$request" | nc -U "$sock" -} - -# --- Main --- - -REPO_ROOT=$(get_repo_root) -SOCK_PATH=$(get_socket_path "$REPO_ROOT") - -case "${1:-}" in - --ping) - ensure_server "$REPO_ROOT" "$SOCK_PATH" - send_request "$SOCK_PATH" '{"command":"ping"}' - ;; - --shutdown) - send_request "$SOCK_PATH" '{"command":"shutdown"}' - ;; - --parse-only) - shift - FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") - ensure_server "$REPO_ROOT" "$SOCK_PATH" - send_request "$SOCK_PATH" "{\"command\":\"parseOnly\",\"file\":\"$FILE\"}" - ;; - --check-project) - ensure_server "$REPO_ROOT" "$SOCK_PATH" - send_request "$SOCK_PATH" '{"command":"checkProject"}' - ;; - --find-refs) - shift - FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") - LINE="$2" - COL="$3" - ensure_server "$REPO_ROOT" "$SOCK_PATH" - send_request "$SOCK_PATH" "{\"command\":\"findRefs\",\"file\":\"$FILE\",\"line\":$LINE,\"col\":$COL}" - ;; - --type-hints) - shift - FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") - START_LINE="$2" - END_LINE="$3" - ensure_server "$REPO_ROOT" "$SOCK_PATH" - send_request "$SOCK_PATH" "{\"command\":\"typeHints\",\"file\":\"$FILE\",\"startLine\":$START_LINE,\"endLine\":$END_LINE}" - ;; - -*) - echo "Usage: get-fsharp-errors [--parse-only] " >&2 - echo " get-fsharp-errors --check-project " >&2 - echo " get-fsharp-errors --ping | --shutdown" >&2 - exit 1 - ;; - *) - FILE=$(cd "$(dirname "$1")" && pwd)/$(basename "$1") - ensure_server "$REPO_ROOT" "$SOCK_PATH" - send_request "$SOCK_PATH" "{\"command\":\"check\",\"file\":\"$FILE\"}" - ;; -esac diff --git a/.github/skills/fsharp-diagnostics/server/Server.fs b/.github/skills/fsharp-diagnostics/server/Server.fs index e45a9b9ad9e..d0defb5c373 100644 --- a/.github/skills/fsharp-diagnostics/server/Server.fs +++ b/.github/skills/fsharp-diagnostics/server/Server.fs @@ -68,6 +68,14 @@ let startServer (config: ServerConfig) = match command with | "ping" -> return $"""{{ "status":"ok", "pid":{Environment.ProcessId} }}""" + | "warmup" -> + // Forces the lazy DesignTimeBuild + FCS project load so the next real + // request doesn't hang for 5-15 min on a cold clone. + let! optionsResult = getOptions () + match optionsResult with + | Ok _ -> return """{ "status":"warmed" }""" + | Error msg -> return $"""{{ "error":"warmup failed: {msg}" }}""" + | "parseOnly" -> let file = doc.RootElement.GetProperty("file").GetString()