From 4ae9fa7cb4857e649b54dab49df1dbfb7abff385 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Thu, 4 Jun 2026 13:37:12 +0200 Subject: [PATCH 1/4] Make fsharp-diagnostics skill cross-platform Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/fsharp-diagnostics/SKILL.md | 30 +-- .../scripts/get-fsharp-errors.ps1 | 188 ++++++++++++++++++ .../scripts/get-fsharp-errors.sh | 118 ----------- 3 files changed, 205 insertions(+), 131 deletions(-) create mode 100644 .github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1 delete mode 100755 .github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh diff --git a/.github/skills/fsharp-diagnostics/SKILL.md b/.github/skills/fsharp-diagnostics/SKILL.md index 846303abe6d..bc2f5fa3085 100644 --- a/.github/skills/fsharp-diagnostics/SKILL.md +++ b/.github/skills/fsharp-diagnostics/SKILL.md @@ -9,40 +9,44 @@ description: "Always invoke after editing .fs files. Provides fast parse/typeche ## Setup (run once per shell session) -```bash -GetErrors() { "$(git rev-parse --show-toplevel)/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.sh" "$@"; } +Works on macOS, Linux, and Windows — 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 } ``` +If your shell is bash/zsh and you don't want to switch, the script also runs as `pwsh -File /get-fsharp-errors.ps1 ...`. + ## Parse first, typecheck second -```bash -GetErrors --parse-only src/Compiler/Checking/CheckBasics.fs +```pwsh +GetErrors -ParseOnly src/Compiler/Checking/CheckBasics.fs ``` If errors → fix syntax. Do NOT typecheck until parse is clean. -```bash +```pwsh GetErrors src/Compiler/Checking/CheckBasics.fs ``` ## Find references for a single symbol (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 ``` ## Type hints for a range selection (begin and end line numbers, 1-based) To see inferred types as inline `// (name: Type)` comments: -```bash -GetErrors --type-hints src/Compiler/TypedTree/TypedTreeOps.fs 1028 1032 +```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 +GetErrors -Shutdown ``` First call starts server (~70s cold start, set initial_wait=600). Auto-shuts down after 4h idle. ~3 GB RAM. 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..ac24901dd98 --- /dev/null +++ b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1 @@ -0,0 +1,188 @@ +<# +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 = 600000 # 10 min for checkProject; safe upper bound + +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 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 } + $log = Get-LogPath $root + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = 'dotnet' + # ArgumentList (Collection) handles per-platform quoting (incl. spaces in paths). + foreach ($a in @('run','-c','Release','--project',$ServerProject,'--','--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) { return } + Start-Sleep -Milliseconds 500 + } + throw "Server failed to start within ${StartTimeoutSec}s. Check log: $log" + } 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 } + +# Skip server start for -Shutdown (would be pointless) and ensure friendly error if absent. +if (-not $Shutdown) { Start-DiagServer $root $sock } + +try { + Send-Request $sock $payload +} catch { + if ($Shutdown) { + Write-Output '{ "status":"not_running" }' + } else { + 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 From 9e628e9cace13bb2baed46458b90955111a9249d Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Thu, 4 Jun 2026 13:43:03 +0200 Subject: [PATCH 2/4] Pre-build server so cold-clone first call doesn't time out Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/fsharp-diagnostics/SKILL.md | 2 +- .../scripts/get-fsharp-errors.ps1 | 35 +++++++++++++++++-- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/.github/skills/fsharp-diagnostics/SKILL.md b/.github/skills/fsharp-diagnostics/SKILL.md index bc2f5fa3085..57c56e23ef7 100644 --- a/.github/skills/fsharp-diagnostics/SKILL.md +++ b/.github/skills/fsharp-diagnostics/SKILL.md @@ -49,4 +49,4 @@ GetErrors -Ping GetErrors -Shutdown ``` -First call starts server (~70s cold start, set initial_wait=600). Auto-shuts down after 4h idle. ~3 GB RAM. +First call on a fresh clone runs `dotnet build -c Release` for the server (~5–15 min for nuget restore + FSharp.Compiler.Service build); set `initial_wait=900`. After that the prebuilt server starts in ~70s (`initial_wait=180`). Auto-shuts down after 4h idle. ~3 GB RAM. diff --git a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1 b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1 index ac24901dd98..39435f85d2f 100644 --- a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1 +++ b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1 @@ -99,6 +99,31 @@ function Test-ServerAlive([string]$sock) { 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 @@ -111,13 +136,17 @@ function Start-DiagServer([string]$root, [string]$sock) { # 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). - foreach ($a in @('run','-c','Release','--project',$ServerProject,'--','--repo-root',$root)) { - [void]$psi.ArgumentList.Add($a) - } + # 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 From 7bf065877b2407dbe84662c5c55451dfd5994ae6 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Thu, 4 Jun 2026 13:48:40 +0200 Subject: [PATCH 3/4] Pre-warm in-memory FCS so first real call doesn't hang Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/fsharp-diagnostics/SKILL.md | 2 +- .../scripts/get-fsharp-errors.ps1 | 38 +++++++++++++------ .../fsharp-diagnostics/server/Server.fs | 8 ++++ 3 files changed, 36 insertions(+), 12 deletions(-) diff --git a/.github/skills/fsharp-diagnostics/SKILL.md b/.github/skills/fsharp-diagnostics/SKILL.md index 57c56e23ef7..3952bfcfd39 100644 --- a/.github/skills/fsharp-diagnostics/SKILL.md +++ b/.github/skills/fsharp-diagnostics/SKILL.md @@ -49,4 +49,4 @@ GetErrors -Ping GetErrors -Shutdown ``` -First call on a fresh clone runs `dotnet build -c Release` for the server (~5–15 min for nuget restore + FSharp.Compiler.Service build); set `initial_wait=900`. After that the prebuilt server starts in ~70s (`initial_wait=180`). Auto-shuts down after 4h idle. ~3 GB RAM. +First call on a fresh clone builds the server then warms its in-memory FSharp.Compiler.Service project (nuget restore + DTB of `src/Compiler/FSharp.Compiler.Service.fsproj` + FCS type-check of ~2M lines, 5–15 min); set `initial_wait=1200`. After that the prebuilt + warmed server answers in seconds (`initial_wait=180`). Auto-shuts down after 4h idle. ~3 GB RAM. diff --git a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1 b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1 index 39435f85d2f..b0dc87f38b4 100644 --- a/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1 +++ b/.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1 @@ -23,7 +23,7 @@ $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 = 600000 # 10 min for checkProject; safe upper bound +$IoTimeoutMs = 1800000 # 30 min - covers cold-clone warmup (nuget restore + FCS in-memory typecheck of ~2M lines) function Show-Usage { @" @@ -159,10 +159,20 @@ function Start-DiagServer([string]$root, [string]$sock) { # 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) { return } + if (Test-ServerAlive $sock) { break } Start-Sleep -Milliseconds 500 } - throw "Server failed to start within ${StartTimeoutSec}s. Check log: $log" + 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 } } @@ -202,16 +212,22 @@ $payload = elseif ($Rest -and $Rest.Count -ge 1) { @{ command = 'check'; file = (Resolve-AbsFile $Rest[0]) } } else { Show-Usage; exit 1 } -# Skip server start for -Shutdown (would be pointless) and ensure friendly error if absent. -if (-not $Shutdown) { Start-DiagServer $root $sock } +# 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 { - if ($Shutdown) { - Write-Output '{ "status":"not_running" }' - } else { - Write-Error "Cannot reach diagnostics server at $sock`: $($_.Exception.Message)" - exit 1 - } + Write-Error "Cannot reach diagnostics server at $sock`: $($_.Exception.Message)" + exit 1 } 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() From 36c51f9392834fa1599be522a9c71e5154c611e5 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Thu, 4 Jun 2026 14:05:28 +0200 Subject: [PATCH 4/4] Tighten fsharp-diagnostics SKILL.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/fsharp-diagnostics/SKILL.md | 36 ++++++++++++---------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/.github/skills/fsharp-diagnostics/SKILL.md b/.github/skills/fsharp-diagnostics/SKILL.md index 3952bfcfd39..51afcdb4b3b 100644 --- a/.github/skills/fsharp-diagnostics/SKILL.md +++ b/.github/skills/fsharp-diagnostics/SKILL.md @@ -1,42 +1,40 @@ --- 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) -Works on macOS, Linux, and Windows — requires pwsh 7+ (`brew install powershell` / `winget install Microsoft.PowerShell` / `apt install powershell`). +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 } ``` -If your shell is bash/zsh and you don't want to switch, the script also runs as `pwsh -File /get-fsharp-errors.ps1 ...`. +From bash/zsh without a function: `pwsh -File /.github/skills/fsharp-diagnostics/scripts/get-fsharp-errors.ps1 `. ## Parse first, typecheck second ```pwsh -GetErrors -ParseOnly src/Compiler/Checking/CheckBasics.fs -``` -If errors → fix syntax. Do NOT typecheck until parse is clean. -```pwsh -GetErrors src/Compiler/Checking/CheckBasics.fs +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: ```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: +Returns the range with inferred types as inline `// (name: Type)` comments: ```pwsh GetErrors -TypeHints src/Compiler/TypedTree/TypedTreeOps.Transforms.fs 100 120 ``` @@ -45,8 +43,14 @@ GetErrors -TypeHints src/Compiler/TypedTree/TypedTreeOps.Transforms.fs 100 120 ```pwsh GetErrors -CheckProject # typecheck entire project -GetErrors -Ping +GetErrors -Ping # liveness check, no side effects GetErrors -Shutdown ``` -First call on a fresh clone builds the server then warms its in-memory FSharp.Compiler.Service project (nuget restore + DTB of `src/Compiler/FSharp.Compiler.Service.fsproj` + FCS type-check of ~2M lines, 5–15 min); set `initial_wait=1200`. After that the prebuilt + warmed server answers in seconds (`initial_wait=180`). 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.