diff --git a/.github/workflows/check-version-bump.yml b/.github/workflows/check-version-bump.yml index 6401013c..9099968f 100644 --- a/.github/workflows/check-version-bump.yml +++ b/.github/workflows/check-version-bump.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout PR branch - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Get PR version id: pr @@ -29,7 +29,7 @@ jobs: Write-Host "PR version: $version (from $path)" - name: Checkout main - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: main path: main-branch diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2b044f6..9b1ba26d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,26 +19,29 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # What used to be the workflow's paths-ignore list. Anything NOT matched here is # code, and only then is there anything to build. - name: Classify changed paths - uses: dorny/paths-filter@v4 + uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 id: filter with: # Positive list, not negations. paths-filter ORs the patterns in a filter, so a # stack of '!' patterns matches whenever a file fails ANY one of them, which for # a docs-only change is always true. Listing what IS code keeps the OR honest. # PlanViewer.Ssms and PlanViewer.Ssms.Installer stay out: they are not in the - # solution and ci.yml never built them. + # solution and ci.yml never built them. server/PlanShare is in: the test project + # references it, so this job builds and tests it. filters: | code: - 'src/PlanViewer.App/**' - 'src/PlanViewer.Cli/**' - 'src/PlanViewer.Core/**' - 'src/PlanViewer.Web/**' + - 'server/PlanShare/**' - 'src/Directory.Build.props' + - 'Directory.Packages.props' - 'tests/**' - 'PlanViewer.sln' - 'global.json' @@ -46,11 +49,13 @@ jobs: - name: Setup .NET 10.0 if: steps.filter.outputs.code == 'true' - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x cache: true - cache-dependency-path: '**/*.csproj' + cache-dependency-path: | + **/*.csproj + Directory.Packages.props - name: Install WASM workload if: steps.filter.outputs.code == 'true' diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index d4d8a8f3..a0dc43f9 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -52,11 +52,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 - - uses: anthropics/claude-code-action@v1 + - uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1.0.236 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} prompt: | diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 60c2d223..5a24133c 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -32,11 +32,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 1 - - uses: anthropics/claude-code-action@v1 + - uses: anthropics/claude-code-action@8ce9314fa9a404564fa7e954cd84f25bcba2b829 # v1.0.236 with: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_args: | diff --git a/.github/workflows/deploy-planshare.yml b/.github/workflows/deploy-planshare.yml index 9cbcd931..2d3876db 100644 --- a/.github/workflows/deploy-planshare.yml +++ b/.github/workflows/deploy-planshare.yml @@ -8,6 +8,7 @@ on: branches: [main] paths: - 'server/PlanShare/**' + - 'Directory.Packages.props' - '.github/workflows/deploy-planshare.yml' workflow_dispatch: @@ -31,10 +32,10 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET 10.0 - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 9e0f8790..682d9324 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -7,6 +7,7 @@ on: - 'src/PlanViewer.Core/**' - 'src/PlanViewer.Web/**' - 'src/Directory.Build.props' + - 'Directory.Packages.props' - '.github/workflows/deploy-web.yml' workflow_dispatch: @@ -27,10 +28,10 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup .NET 10.0 - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x @@ -50,10 +51,10 @@ jobs: run: cp publish/wwwroot/index.html publish/wwwroot/404.html - name: Upload Pages artifact - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: publish/wwwroot - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index f86ef58f..399827c8 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -15,7 +15,7 @@ jobs: outputs: has_changes: ${{ steps.check.outputs.has_changes }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: dev fetch-depth: 0 @@ -38,12 +38,12 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: dev - name: Setup .NET 10.0 - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fb6e9c0d..022caa7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Get version id: version @@ -44,7 +44,7 @@ jobs: # window lapsed. Nothing before the signing step publishes anything now. - name: Setup .NET 10.0 - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: 10.0.x @@ -68,7 +68,7 @@ jobs: # The build step never fails the job (it only sets an output on success), # so a VSIX build failure can never block the cross-platform app release. - name: Add MSBuild to PATH - uses: microsoft/setup-msbuild@v3 + uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3.0.0 continue-on-error: true - name: Build SSMS extension @@ -124,7 +124,7 @@ jobs: - name: Upload Windows build for signing id: upload-unsigned - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: App-unsigned path: publish/win-x64/ @@ -136,7 +136,7 @@ jobs: # fix the cause and start a fresh run (a new dev -> main merge) instead of # re-running the same one. - name: Sign Windows build - uses: signpath/github-action-submit-signing-request@v3 + uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0 with: api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' @@ -160,6 +160,107 @@ jobs: Remove-Item -Recurse -Force publish/win-x64 Copy-Item -Recurse signed/win-x64 publish/win-x64 + # ── SignPath code signing for the SSMS extension and its installer. + # Unlike the App, this is best-effort. If a step below fails, or an + # approval times out, the release still goes out with the unsigned + # files (as it did before these were signed) and a warning annotation + # says so. The one exception is in the replace step: if it cannot put + # the unsigned files back after a failed copy or a failed signature + # check, the job stops before anything is published. Each SignPath + # request needs a manual approval, so a release run now waits for two: + # the App first, then these two files. ── + - name: Stage SSMS files for signing + if: steps.ssms.outputs.BUILT == 'true' + continue-on-error: true + shell: pwsh + run: | + # Exactly these two files, at the root of one folder. The SignPath + # "Vsix" artifact configuration matches them by that root-relative path. + New-Item -ItemType Directory -Force -Path ssms-unsigned | Out-Null + Copy-Item releases/InstallSsmsExtension.exe ssms-unsigned/InstallSsmsExtension.exe + Copy-Item releases/PlanViewer.Ssms.vsix ssms-unsigned/PlanViewer.Ssms.vsix + + - name: Upload SSMS files for signing + id: upload-ssms-unsigned + if: steps.ssms.outputs.BUILT == 'true' + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: Ssms-unsigned + path: ssms-unsigned/ + if-no-files-found: error + + # Same organization, project, policy and timeout as the App request above. + # Only the artifact configuration differs. "Vsix" is a zip whose root holds + # InstallSsmsExtension.exe (Authenticode) and PlanViewer.Ssms.vsix (OPC + # signature, plus Authenticode on PlanViewer.Ssms.dll, which sits at the + # root of the VSIX). + - name: Sign SSMS extension and installer + id: sign-ssms + if: steps.ssms.outputs.BUILT == 'true' + continue-on-error: true + uses: signpath/github-action-submit-signing-request@f6d04783b4569d051e0c80105fe66e82819d0092 # v3.0 + with: + api-token: '${{ secrets.SIGNPATH_API_TOKEN }}' + organization-id: '7969f8b6-d946-4a74-9bac-a55856d8b8e0' + project-slug: 'PerformanceStudio' + signing-policy-slug: 'release-signing' + artifact-configuration-slug: 'Vsix' + github-artifact-id: '${{ steps.upload-ssms-unsigned.outputs.artifact-id }}' + wait-for-completion: true + output-artifact-directory: 'signed/ssms' + wait-for-completion-timeout-in-seconds: 1800 + + # Copy the signed files over the ones in releases/ only when signing + # succeeded (steps..outcome is the result before continue-on-error, so + # it is never 'success' for a failed step). Every later step reads the + # files from releases/: the release upload, the SSMS Gallery upload and the + # checksums. + - name: Replace unsigned SSMS files with signed + if: steps.ssms.outputs.BUILT == 'true' && steps.sign-ssms.outcome == 'success' + shell: pwsh + run: | + # Copy both files or neither, so one signed file never ships beside an + # unsigned one. The signed installer must also accept the signed VSIX: + # --verify-only runs the installer's signature check and installs + # nothing. If a copy or that check fails, put the unsigned pair back + # from ssms-unsigned/. If that fails too, the step fails and the job + # stops before the release is created, so a mixed pair is never + # published. + $names = 'InstallSsmsExtension.exe', 'PlanViewer.Ssms.vsix' + $missing = @($names | Where-Object { -not (Test-Path "signed/ssms/$_") }) + if ($missing.Count -gt 0) { + Write-Host "::warning::SignPath reported success but these signed files are missing: $($missing -join ', '). PlanViewer.Ssms.vsix and InstallSsmsExtension.exe shipped unsigned." + } else { + try { + foreach ($name in $names) { + Copy-Item "signed/ssms/$name" "releases/$name" -Force -ErrorAction Stop + } + & releases/InstallSsmsExtension.exe --verify-only releases/PlanViewer.Ssms.vsix + if ($LASTEXITCODE -ne 0) { + throw "the installer rejected the signed VSIX with exit code $LASTEXITCODE (the reason is in the log above)" + } + Write-Host 'Replaced the SSMS extension and installer with the signed files.' + } catch { + $problem = $_.Exception.Message + foreach ($name in $names) { + Copy-Item "ssms-unsigned/$name" "releases/$name" -Force -ErrorAction Stop + } + # The failed check left a non-zero exit code, and the shell ends the + # script with `exit $LASTEXITCODE`. The failure is handled, so reset it. + $global:LASTEXITCODE = 0 + Write-Host "::warning::Could not use the signed SSMS files: $problem. PlanViewer.Ssms.vsix and InstallSsmsExtension.exe shipped unsigned." + } + } + + - name: Warn that SSMS files are unsigned + if: steps.ssms.outputs.BUILT == 'true' && steps.sign-ssms.outcome != 'success' + shell: pwsh + env: + SIGN_OUTCOME: ${{ steps.sign-ssms.outcome }} + run: | + Write-Host "::warning::SSMS signing did not succeed (result: $env:SIGN_OUTCOME). PlanViewer.Ssms.vsix and InstallSsmsExtension.exe shipped unsigned." + # ── Everything above this line is reversible: nothing has been published. # The release is created only now that signed binaries exist. ── - name: Create release @@ -170,8 +271,12 @@ jobs: run: | gh release create "v${{ steps.version.outputs.VERSION }}" --title "v${{ steps.version.outputs.VERSION }}" --generate-notes --target main + # releases/ holds the signed copies here when the SSMS signing above succeeded. + # continue-on-error: the release exists by now, so a failed SSMS upload must + # not stop the App files from being uploaded below. - name: Upload SSMS extension to release if: steps.ssms.outputs.BUILT == 'true' + continue-on-error: true shell: pwsh env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -215,6 +320,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.VERSION }} + SSMS_BUILT: ${{ steps.ssms.outputs.BUILT }} run: | New-Item -ItemType Directory -Force -Path releases @@ -271,8 +377,14 @@ jobs: python .github/scripts/zip_with_exec.py "$wrapperDir" "releases/PerformanceStudio-$rid.zip" --exec "PerformanceStudio.app/Contents/MacOS/PlanViewer.App" --exec-optional "PerformanceStudio.app/Contents/MacOS/createdump" } - # Checksums (zips only, Velopack has its own checksums) - $checksums = Get-ChildItem releases/*.zip | ForEach-Object { + # Checksums: the zips, plus the SSMS extension and installer when they + # were built (the signed copies when signing succeeded). Velopack has + # its own checksums. + $files = @(Get-ChildItem releases/*.zip) + if ($env:SSMS_BUILT -eq 'true') { + $files += Get-Item releases/PlanViewer.Ssms.vsix, releases/InstallSsmsExtension.exe + } + $checksums = $files | ForEach-Object { $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower() "$hash $($_.Name)" } diff --git a/.signpath/policies/PerformanceStudio/release-signing.yml b/.signpath/policies/PerformanceStudio/release-signing.yml index bfcc85a4..80e86213 100644 --- a/.signpath/policies/PerformanceStudio/release-signing.yml +++ b/.signpath/policies/PerformanceStudio/release-signing.yml @@ -5,19 +5,20 @@ # project-slug: PerformanceStudio # signing-policy-slug: release-signing # -# This file only takes effect from the repository's DEFAULT branch (main). On any -# other branch it is inert, so changes here do nothing until they are merged to -# main via the usual dev -> main release merge. -# -# Since release.yml moved to v3 of signpath/github-action-submit-signing-request -# (SignPath's Pipeline Connector), SignPath reads this file only if the signing -# policy references it as a Pipeline Policy in the SignPath dashboard (SignPath -# changelog, Pipeline Connector 0.8.0, 2026-09-09). Without that reference the -# runner rule below is not enforced. +# NOT ENFORCED. Since release.yml moved to v3 of +# signpath/github-action-submit-signing-request (SignPath's Pipeline Connector), +# SignPath reads this file only if the signing policy references it as a +# Pipeline Policy (SignPath changelog, Pipeline Connector 0.8.0, 2026-09-09). +# This organization is on SignPath's OSS subscription, and its dashboard shows +# no Pipeline Policy setting (checked 2026-09-25), so nothing references this +# file and SignPath does not check the rule below. The file records the intended +# rule in case that setting becomes available. The v2 connector read this file +# automatically, but only from the repository's default branch (main). # # What this file does NOT do: it does not enable automatic approval. Approval mode -# lives on the signing policy in the SignPath dashboard. This file adds constraints -# that SignPath enforces on a build before it is willing to sign it. +# lives on the signing policy in the SignPath dashboard, next to the checks that +# do apply today: trusted build system, origin verification and allowed branch +# names. github-policies: runners: diff --git a/CITATION.cff b/CITATION.cff index 72cc0fe1..d56e0c2f 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -9,8 +9,8 @@ authors: website: "https://erikdarling.com" repository-code: "https://github.com/erikdarlingdata/PerformanceStudio" license: MIT -version: "1.27.0" -date-released: "2026-09-24" +version: "1.28.0" +date-released: "2026-09-29" keywords: - sql-server - execution-plan diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000..17976eea --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,46 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index 847bbeb1..25f0dcb2 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,19 @@ planview analyze ./queries/ --output-dir ./results/ CLI arguments override `.env` values when both are provided. +When the `.env` file supplies a setting, the CLI prints one line to stderr. The line names the file and those settings, and it never shows their values: + +```text +Using settings from /work/.env: PLANVIEW_SERVER, PLANVIEW_TRUST_CERT +``` + +The CLI takes a setting from the file only when the setting has an effect: + +- With no server from either place, `analyze` reads the plan file offline and takes nothing from the `.env` file. +- `PLANVIEW_PASSWORD` is used only with a login. + +If a `PLANVIEW_` value holds a control character, the CLI stops and names the setting. The CLI prints the server and database names later. A control character in them moves the cursor and erases text, such as the `Using settings` line. + **Using the credential store** — for longer-term use, store credentials in your OS keychain: ```bash @@ -380,6 +393,8 @@ A VSIX extension that adds **"Open in Performance Studio"** to the execution pla 4. The installer auto-detects SSMS 21 and/or SSMS 22 and installs into both 5. Restart SSMS to activate the extension +Release builds are signed. A signed installer installs only a `PlanViewer.Ssms.vsix` that has the same signature, so use the two files from the same release. You can also double-click `PlanViewer.Ssms.vsix` to install the extension without the installer. + ### First run On first use, if Performance Studio isn't found automatically, the extension will prompt you to locate `PlanViewer.App.exe`. The path is saved to the registry (`HKCU\SOFTWARE\DarlingData\SQLPerformanceStudio\InstallPath`) so you only need to do this once. @@ -462,7 +477,7 @@ Arguments: Options: --stdin Read plan XML from stdin - -o, --output json (default) or text + -o, --output json (default), text, or both (with --server, both writes .json and .txt files) --compact Compact JSON (no indentation) --warnings-only Skip operator tree, only output warnings and indexes -s, --server SQL Server name (matches credential store key) diff --git a/server/PlanShare/ClientKey.cs b/server/PlanShare/ClientKey.cs new file mode 100644 index 00000000..54dfdda7 --- /dev/null +++ b/server/PlanShare/ClientKey.cs @@ -0,0 +1,33 @@ +using System.Net; +using System.Net.Sockets; + +namespace PlanShare; + +/// +/// The one key every per-client limit (share, analytics, read, upload budget) is counted under. +/// An IPv4 address is its own key. An IPv4-mapped IPv6 address (how a dual-stack socket reports +/// an IPv4 caller) becomes that IPv4 address, so one caller has one key. Any other IPv6 address +/// becomes its /64 prefix: a single subscriber is normally given a whole /64, so counting per /64 +/// makes one subscriber one client, however many addresses they use. +/// +internal static class ClientKey +{ + public const string Unknown = "unknown"; + + public static string From(IPAddress? address) + { + if (address is null) + return Unknown; + + if (address.IsIPv4MappedToIPv6) + address = address.MapToIPv4(); + + if (address.AddressFamily != AddressFamily.InterNetworkV6) + return address.ToString(); + + Span bytes = stackalloc byte[16]; + address.TryWriteBytes(bytes, out _); + bytes[8..].Clear(); + return $"{new IPAddress(bytes)}/64"; + } +} diff --git a/server/PlanShare/PlanShare.csproj b/server/PlanShare/PlanShare.csproj index 0d1f7756..8ac93905 100644 --- a/server/PlanShare/PlanShare.csproj +++ b/server/PlanShare/PlanShare.csproj @@ -7,7 +7,7 @@ - + - + + + + + + diff --git a/server/PlanShare/Program.cs b/server/PlanShare/Program.cs index a144de34..d70657b4 100644 --- a/server/PlanShare/Program.cs +++ b/server/PlanShare/Program.cs @@ -4,6 +4,7 @@ using System.Text.Json; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Data.Sqlite; +using PlanShare; var builder = WebApplication.CreateBuilder(args); @@ -14,8 +15,9 @@ policy.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader()); }); -// Database path — data/ subdirectory relative to the binary -var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); +// Database path — data/ subdirectory relative to the binary. PlanShare:DataDir moves it +// (the endpoint tests and local runs point it at a temp folder). +var dataDir = builder.Configuration["PlanShare:DataDir"] ?? Path.Combine(AppContext.BaseDirectory, "data"); Directory.CreateDirectory(dataDir); var dbPath = Path.Combine(dataDir, "plans.db"); var connectionString = $"Data Source={dbPath}"; @@ -71,9 +73,23 @@ created_at TEXT NOT NULL // store; 120/min per IP is far above any human's browsing rate. var readRateLimiter = new RateLimiter(maxRequests: 120, windowSeconds: 60); +// --- Storage limit and daily upload budget --- +// The production disk is 40 GB, so shares are refused (507) once the database uses 10 GB of pages. +// The budget is the most plan data one client key can store per UTC day (429 after that). +// PlanShare:MaxDatabaseBytes and PlanShare:DailyUploadBytes override the two limits, which lets +// the endpoint tests use small values. +const long MaxDatabaseBytes = 10L * 1024 * 1024 * 1024; +const long DailyUploadBytes = 100L * 1024 * 1024; +var storageCheck = new StorageCheck( + connectionString, + builder.Configuration.GetValue("PlanShare:MaxDatabaseBytes") ?? MaxDatabaseBytes); +var uploadBudget = new UploadBudget( + builder.Configuration.GetValue("PlanShare:DailyUploadBytes") ?? DailyUploadBytes); + // Register the cleanup background service builder.Services.AddSingleton(new PlanDbConfig(connectionString)); builder.Services.AddSingleton(new RateLimiters(rateLimiter, analyticsRateLimiter, readRateLimiter)); +builder.Services.AddSingleton(uploadBudget); builder.Services.AddHostedService(); // Request size limit (10 MB) @@ -105,6 +121,9 @@ created_at TEXT NOT NULL const int MaxTtlDays = 365; +// Longest page path an analytics event may carry. Real paths are a few characters. +const int MaxEventPathLength = 512; + // Depth ceiling for parsing an uploaded share, mirroring PlanViewer.Core's // AnalysisJson.MaxDepth — that class is the source of truth for how deep a serialized // AnalysisResult can go (#431: an operator costs two JSON levels, so the JsonDocument @@ -121,11 +140,11 @@ created_at TEXT NOT NULL app.MapPost("/api/share", async (HttpContext ctx) => { - // Rate limit by IP - var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - if (!rateLimiter.IsAllowed(ip)) + // Rate limit by client key (IPv4 address, or the /64 of an IPv6 address) + var client = ClientKey.From(ctx.Connection.RemoteIpAddress); + if (!rateLimiter.IsAllowed(client)) { - return Results.StatusCode(429); + return Error(429, "Too many shares from your network. Please wait a minute and try again."); } // Read raw body @@ -134,7 +153,7 @@ created_at TEXT NOT NULL if (string.IsNullOrWhiteSpace(body)) { - return Results.BadRequest("Empty body"); + return Error(400, "Empty body"); } // Parse and extract ttl_days from the JSON. shareDocumentOptions, not defaults: this body @@ -144,12 +163,31 @@ created_at TEXT NOT NULL try { using var doc = JsonDocument.Parse(body, shareDocumentOptions); - if (doc.RootElement.TryGetProperty("ttl_days", out var ttlProp) && ttlProp.TryGetInt32(out var t)) - ttlDays = Math.Clamp(t, 1, MaxTtlDays); + // TryGetProperty and TryGetInt32 throw (a 500) on the wrong kind of element, so check the kind first + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return Error(400, "The request body must be a JSON object."); + if (doc.RootElement.TryGetProperty("ttl_days", out var ttlProp) && ttlProp.ValueKind != JsonValueKind.Null) + { + if (ttlProp.ValueKind != JsonValueKind.Number) + return Error(400, "ttl_days must be a number."); + if (ttlProp.TryGetInt32(out var t)) + ttlDays = Math.Clamp(t, 1, MaxTtlDays); + } } catch (JsonException) { - return Results.BadRequest("Invalid JSON"); + return Error(400, "Invalid JSON"); + } + + if (storageCheck.IsFull()) + { + return Error(507, "Plan sharing is full right now. Please try again later."); + } + + // The stored size is the UTF-8 length of the body; ContentLength can be absent + if (!uploadBudget.TryCharge(client, Encoding.UTF8.GetByteCount(body))) + { + return Error(429, "Daily sharing limit reached for your network. Please try again tomorrow."); } var id = GenerateId(); @@ -175,8 +213,8 @@ created_at TEXT NOT NULL app.MapGet("/api/plans/{id}", (string id, HttpContext ctx) => { - var readIp = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - if (!readRateLimiter.IsAllowed(readIp)) + var readClient = ClientKey.From(ctx.Connection.RemoteIpAddress); + if (!readRateLimiter.IsAllowed(readClient)) return Results.StatusCode(429); using var conn = new SqliteConnection(connectionString); @@ -199,9 +237,8 @@ created_at TEXT NOT NULL app.MapPost("/api/event", async (HttpContext ctx) => { - // Rate limit: 30 events/min per IP (generous — covers page nav + shares) - var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - if (!analyticsRateLimiter.IsAllowed(ip)) + // Rate limit: 30 events/min per client key (generous — covers page nav + shares) + if (!analyticsRateLimiter.IsAllowed(ClientKey.From(ctx.Connection.RemoteIpAddress))) return Results.StatusCode(429); using var reader = new StreamReader(ctx.Request.Body); @@ -212,16 +249,32 @@ created_at TEXT NOT NULL try { using var doc = JsonDocument.Parse(body); - if (doc.RootElement.TryGetProperty("path", out var p)) + // GetString() throws (a 500) on a non-string element, so check the kind first. + // JSON null counts as "not sent", like a missing property. + if (doc.RootElement.ValueKind != JsonValueKind.Object) + return Error(400, "The request body must be a JSON object."); + if (doc.RootElement.TryGetProperty("path", out var p) && p.ValueKind != JsonValueKind.Null) + { + if (p.ValueKind != JsonValueKind.String) + return Error(400, "path must be a string."); path = p.GetString() ?? "/"; - if (doc.RootElement.TryGetProperty("referrer", out var r)) + } + if (doc.RootElement.TryGetProperty("referrer", out var r) && r.ValueKind != JsonValueKind.Null) + { + if (r.ValueKind != JsonValueKind.String) + return Error(400, "referrer must be a string."); referrer = r.GetString(); + } } - catch (JsonException) + catch (Exception ex) when (ex is JsonException or InvalidOperationException) { - return Results.BadRequest("Invalid JSON"); + // InvalidOperationException: GetString() on a string with a lone surrogate escape + return Error(400, "Invalid JSON"); } + if (path.Length > MaxEventPathLength) + return Error(400, $"path must be {MaxEventPathLength} characters or fewer."); + // Strip referrer to domain only (no full URLs with query params). // If it doesn't parse as an absolute URL, drop it — never persist raw // client-supplied strings, since the dashboard renders referrers in HTML. @@ -237,11 +290,18 @@ created_at TEXT NOT NULL // space (IPv4 = 2^32, guessable UA, known date) is small enough to brute // force straight back to the source IP, so an unsalted digest would still be // personal data despite looking like a hash. + // The hash takes the full address, not the client key: a /64 would count every host in + // it as one visitor. + var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; var ua = ctx.Request.Headers.UserAgent.FirstOrDefault() ?? ""; var day = DateTime.UtcNow.ToString("yyyy-MM-dd"); var visitorHash = Convert.ToHexString( HMACSHA256.HashData(visitorSalt, Encoding.UTF8.GetBytes($"{ip}|{ua}|{day}"))).ToLower()[..16]; + // A full store drops the event and still answers 200: analytics must never show an error + if (storageCheck.IsFull()) + return Results.Ok(); + using var conn = new SqliteConnection(connectionString); conn.Open(); using var cmd = conn.CreateCommand(); @@ -409,10 +469,14 @@ GROUP BY referrer ORDER BY count DESC LIMIT 10 app.MapDelete("/api/plans/{id}", (string id, HttpContext ctx) => { - var token = ctx.Request.Query["token"].FirstOrDefault(); + // Header first: a ?token= query string is written to the nginx access log. The query form + // stays for clients built before the header existed. + var token = ctx.Request.Headers["X-Delete-Token"].FirstOrDefault(); + if (string.IsNullOrEmpty(token)) + token = ctx.Request.Query["token"].FirstOrDefault(); if (string.IsNullOrEmpty(token)) { - return Results.BadRequest("Missing delete token"); + return Error(400, "Missing delete token"); } using var conn = new SqliteConnection(connectionString); @@ -443,6 +507,12 @@ static string GenerateDeleteToken() return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLower(); } +// A refusal the web client can show: it reads the "error" text out of the body. +static IResult Error(int statusCode, string message) +{ + return Results.Json(new { error = message }, statusCode: statusCode); +} + // --- Supporting types --- record PlanDbConfig(string ConnectionString); @@ -453,12 +523,14 @@ sealed class CleanupService : BackgroundService { private readonly PlanDbConfig _config; private readonly RateLimiters _rateLimiters; + private readonly UploadBudget _uploadBudget; private readonly ILogger _logger; - public CleanupService(PlanDbConfig config, RateLimiters rateLimiters, ILogger logger) + public CleanupService(PlanDbConfig config, RateLimiters rateLimiters, UploadBudget uploadBudget, ILogger logger) { _config = config; _rateLimiters = rateLimiters; + _uploadBudget = uploadBudget; _logger = logger; } @@ -503,14 +575,16 @@ private void Cleanup() // Evict stale rate-limiter keys so the dictionaries don't grow forever. // Every limiter must be swept here: an unswept one keeps a permanent - // entry per unique client IP for the lifetime of the process. + // entry per unique client IP for the lifetime of the process. The upload + // budget is swept the same way (its entries go stale at the UTC day change). var shareEvicted = _rateLimiters.Share.Sweep(); var analyticsEvicted = _rateLimiters.Analytics.Sweep(); var readEvicted = _rateLimiters.Read.Sweep(); - if (shareEvicted + analyticsEvicted + readEvicted > 0) + var budgetEvicted = _uploadBudget.Sweep(); + if (shareEvicted + analyticsEvicted + readEvicted + budgetEvicted > 0) _logger.LogInformation( - "Evicted {Share} share + {Analytics} analytics + {Read} read rate-limit keys", - shareEvicted, analyticsEvicted, readEvicted); + "Evicted {Share} share + {Analytics} analytics + {Read} read rate-limit keys and {Budget} upload-budget keys", + shareEvicted, analyticsEvicted, readEvicted, budgetEvicted); } catch (Exception ex) { diff --git a/server/PlanShare/StorageCheck.cs b/server/PlanShare/StorageCheck.cs new file mode 100644 index 00000000..7e66cede --- /dev/null +++ b/server/PlanShare/StorageCheck.cs @@ -0,0 +1,41 @@ +using Microsoft.Data.Sqlite; + +namespace PlanShare; + +/// +/// Tells whether the plan database has reached its size limit. Size means the pages in use: +/// (page_count - freelist_count) * page_size. The freelist is left out on purpose, because +/// deleting expired plans returns their pages to it and the file itself does not shrink, so +/// the file length would stay above the limit after cleanup had freed the room. +/// +internal sealed class StorageCheck +{ + private readonly string _connectionString; + private readonly long _maxBytes; + + public StorageCheck(string connectionString, long maxBytes) + { + _connectionString = connectionString; + _maxBytes = maxBytes; + } + + public long UsedBytes() + { + using var conn = new SqliteConnection(_connectionString); + conn.Open(); + var pageCount = Pragma(conn, "page_count"); + var freePages = Pragma(conn, "freelist_count"); + var pageSize = Pragma(conn, "page_size"); + return (pageCount - freePages) * pageSize; + } + + /// True at or above the limit. + public bool IsFull() => UsedBytes() >= _maxBytes; + + private static long Pragma(SqliteConnection conn, string name) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = $"PRAGMA {name};"; + return (long)cmd.ExecuteScalar()!; + } +} diff --git a/server/PlanShare/UploadBudget.cs b/server/PlanShare/UploadBudget.cs new file mode 100644 index 00000000..10a9f3d4 --- /dev/null +++ b/server/PlanShare/UploadBudget.cs @@ -0,0 +1,88 @@ +using System.Collections.Concurrent; + +namespace PlanShare; + +/// +/// Caps how many bytes of plan data one client key can store per UTC day. In memory like +/// RateLimiter, so a restart forgets it. A charge is made before the insert and is not refunded +/// if the insert then fails: refunding would need a second code path for a rare error, and the +/// most a client can lose that way is one upload's worth of its allowance. +/// +internal sealed class UploadBudget +{ + private sealed class Usage + { + public DateOnly Day; + public long Bytes; + public bool Evicted; + } + + private readonly long _dailyLimitBytes; + private readonly TimeProvider _clock; + private readonly ConcurrentDictionary _usage = new(); + + public UploadBudget(long dailyLimitBytes, TimeProvider? clock = null) + { + _dailyLimitBytes = dailyLimitBytes; + _clock = clock ?? TimeProvider.System; + } + + private DateOnly Today() => DateOnly.FromDateTime(_clock.GetUtcNow().UtcDateTime); + + /// + /// Adds to the key's total for today and returns true, or returns + /// false and adds nothing when that would take the total over the daily limit. + /// + public bool TryCharge(string key, long bytes) + { + var today = Today(); + + while (true) + { + var usage = _usage.GetOrAdd(key, _ => new Usage { Day = today }); + + lock (usage) + { + // Sweep() removed this entry between GetOrAdd and the lock; charging it now + // would be lost, so fetch or create the live one. + if (usage.Evicted) + continue; + + if (usage.Day != today) + { + usage.Day = today; + usage.Bytes = 0; + } + + if (usage.Bytes + bytes > _dailyLimitBytes) + return false; + + usage.Bytes += bytes; + return true; + } + } + } + + /// + /// Evicts keys whose last charge was on an earlier UTC day. Call periodically so the + /// dictionary doesn't grow forever across unique clients. + /// Returns the number of keys evicted. + /// + public int Sweep() + { + var today = Today(); + var evicted = 0; + foreach (var kvp in _usage) + { + lock (kvp.Value) + { + if (kvp.Value.Day != today && _usage.TryRemove(kvp)) + { + kvp.Value.Evicted = true; + evicted++; + } + } + } + return evicted; + } +} diff --git a/server/PlanShare/dashboard.html b/server/PlanShare/dashboard.html index 3b24ac0a..3b458157 100644 --- a/server/PlanShare/dashboard.html +++ b/server/PlanShare/dashboard.html @@ -235,7 +235,9 @@

Top Referrers (30 days)

if (m) { var t = decodeURIComponent(m[1]); try { localStorage.setItem("planshare_stats_token", t); } catch (e) { /* private mode */ } - history.replaceState(null, "", window.location.pathname + window.location.search); + // window.history: a bare "history" here is the stats array declared at the top of this + // script, which has no replaceState, so the call threw and left #token= in the address bar. + window.history.replaceState(null, "", window.location.pathname + window.location.search); return t; } try { return localStorage.getItem("planshare_stats_token") || ""; } catch (e) { return ""; } diff --git a/src/Directory.Build.props b/src/Directory.Build.props index f07613cb..15c2c2b2 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -15,7 +15,7 @@ Tests and server/ projects are outside src/ and are unaffected. --> - 1.27.0 + 1.28.0 Erik Darling Darling Data LLC Performance Studio diff --git a/src/PlanViewer.App/AboutWindow.axaml.cs b/src/PlanViewer.App/AboutWindow.axaml.cs index b9e87deb..b15e623e 100644 --- a/src/PlanViewer.App/AboutWindow.axaml.cs +++ b/src/PlanViewer.App/AboutWindow.axaml.cs @@ -235,16 +235,30 @@ unsaved work to lose and restarting without a walk is genuinely safe. */ private void CloseButton_Click(object? sender, RoutedEventArgs e) => Close(); + /// + /// The address as a web page to open, or null when it is not an absolute http or https + /// address. The update address comes from the release server's reply, and the shell + /// runs a program when it is handed a file path or another scheme. + /// + internal static Uri? WebPageAddress(string? url) => + Uri.TryCreate(url, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttps || uri.Scheme == Uri.UriSchemeHttp) + ? uri + : null; + private static void OpenUrl(string url) { + if (WebPageAddress(url) is not { } page) + return; + try { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - Process.Start(new ProcessStartInfo { FileName = url, UseShellExecute = true }); + Process.Start(new ProcessStartInfo { FileName = page.AbsoluteUri, UseShellExecute = true }); else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - Process.Start("open", url); + Process.Start("open", page.AbsoluteUri); else - Process.Start("xdg-open", url); + Process.Start("xdg-open", page.AbsoluteUri); } catch { diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Parameters.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Parameters.cs index 3dc2da82..84fbeee1 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.Parameters.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Parameters.cs @@ -131,9 +131,11 @@ private void ShowParameters(PlanStatement statement) // Annotations if (allCompiledNull && parameters.Count > 0) { + // #579: the phrase inside a string literal or a comment is not a hint. var hasOptimizeForUnknown = statement.StatementText .Contains("OPTIMIZE", StringComparison.OrdinalIgnoreCase) - && Regex.IsMatch(statement.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase); + && Regex.IsMatch(PlanAnalyzer.MaskCommentsAndLiterals(statement.StatementText), + @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase); if (hasOptimizeForUnknown) { diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs index a5f0f441..2c525959 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs @@ -295,15 +295,18 @@ private Border CreateNodeVisual(PlanNode node, double divergenceLimit, int total }); // Actual rows of Estimated rows (accuracy %) -- red if off by divergence limit - var estRows = node.EstimateRows; - var accuracyRatio = estRows > 0 ? node.ActualRows / estRows : (node.ActualRows > 0 ? double.MaxValue : 1.0); + // #594: EstimateRows is per execution. On the inner side of a Nested Loops join, + // ActualExecutions is a real per-execution count, so the estimate has to scale by it + // to compare fairly against the summed ActualRows. Everywhere else — including a + // parallel zone, where ActualExecutions just counts threads — the estimate stays + // per-execution. RowEstimateHelper is the one place that decides which applies. + // #611: PlanRowAccuracy adds decimals where N0 would print numbers that contradict + // the percentage ("1 of 1 (89%)"). + var accuracyRatio = RowEstimateHelper.GetRowAccuracyRatio(node); IBrush rowBrush = (accuracyRatio < 1.0 / divergenceLimit || accuracyRatio > divergenceLimit) ? OrangeRedBrush : fgBrush; - var accuracy = estRows > 0 - ? $" ({accuracyRatio * 100:F0}%)" - : ""; stack.Children.Add(new TextBlock { - Text = $"{node.ActualRows:N0} of {estRows:N0}{accuracy}", + Text = PlanRowAccuracy.FormatActualOfExpected(node.ActualRows, RowEstimateHelper.GetExpectedRows(node)), FontSize = 10, Foreground = rowBrush, TextAlignment = TextAlignment.Center, @@ -382,10 +385,8 @@ private static IBrush GetLinkColorBrush(PlanNode child, double divergenceLimit) return EdgeBrush; divergenceLimit = Math.Max(2.0, divergenceLimit); - var estRows = child.EstimateRows; - var accuracyRatio = estRows > 0 - ? child.ActualRows / estRows - : (child.ActualRows > 0 ? double.MaxValue : 1.0); + // #594: same per-execution-vs-total comparison as the node label, via the shared helper. + var accuracyRatio = RowEstimateHelper.GetRowAccuracyRatio(child); // Within the neutral band — keep default color if (accuracyRatio >= 1.0 / divergenceLimit && accuracyRatio <= divergenceLimit) diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs b/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs index 5f8306c2..bbb482dd 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs @@ -30,7 +30,8 @@ private void ShowRuntimeSummary(PlanStatement statement) }; int rowIndex = 0; - void AddRow(string label, string value, string? brushKey = null) + // nested: the row is a detail of the row above it, so its label is indented under that row's. + void AddRow(string label, string value, string? brushKey = null, bool nested = false) { grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); @@ -40,7 +41,7 @@ void AddRow(string label, string value, string? brushKey = null) FontSize = 11, Foreground = labelBrush, HorizontalAlignment = HorizontalAlignment.Left, - Margin = new Thickness(0, 1, 8, 1) + Margin = new Thickness(nested ? 12 : 0, 1, 8, 1) }; Grid.SetRow(labelText, rowIndex); Grid.SetColumn(labelText, 0); @@ -84,7 +85,8 @@ static string MemoryGrantBrushKey(double pctUsed, bool hasSpill) var hasSpillInTree = statement.RootNode != null && HasSpillInPlanTree(statement.RootNode); - // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → Optimization → CE Model → Cost. + // E11: order — Elapsed → CPU:Elapsed → DOP → CPU → Compile → Memory → Used → CE Model → Optimization → Cost. + // #613 moved CE Model above Optimization, so Optimization and its nested early abort reason end the list. // Extra Avalonia-only rows (threads, UDF, cached plan size) kept near their logical neighbors. if (statement.QueryTimeStats != null) @@ -147,13 +149,22 @@ static string MemoryGrantBrushKey(double pctUsed, bool hasSpill) if (statement.MemoryGrant != null) { var mg = statement.MemoryGrant; - var grantPct = mg.GrantedMemoryKB > 0 - ? (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100 : 100; - var grantBrushKey = MemoryGrantBrushKey(grantPct, hasSpillInTree); var spillTag = hasSpillInTree ? " ⚠ spill" : ""; - AddRow("Memory grant", - $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%){spillTag}", - grantBrushKey); + if (mg.GrantedMemoryKB > 0) + { + var grantPct = (double)mg.MaxUsedMemoryKB / mg.GrantedMemoryKB * 100; + var grantBrushKey = MemoryGrantBrushKey(grantPct, hasSpillInTree); + AddRow("Memory grant", + $"{TextFormatter.FormatMemoryGrantKB(mg.GrantedMemoryKB)} granted, {TextFormatter.FormatMemoryGrantKB(mg.MaxUsedMemoryKB)} used ({grantPct:N0}%){spillTag}", + grantBrushKey); + } + else + { + // #595: a 0 KB grant isn't "0 KB used (100%)" — there was nothing to use a + // percentage of. Say so plainly, in the same neutral color as every other + // row that isn't flagging a problem. + AddRow("Memory grant", $"No memory grant{spillTag}"); + } if (mg.GrantWaitTimeMs > 0) AddRow("Grant wait", $"{mg.GrantWaitTimeMs:N0}ms", "ErrorBrush"); } @@ -179,13 +190,15 @@ static string MemoryGrantBrushKey(double pctUsed, bool hasSpill) } } - // Optimization + CE model - if (!string.IsNullOrEmpty(statement.StatementOptmLevel)) - AddRow("Optimization", statement.StatementOptmLevel); - if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) - AddRow("Early abort", statement.StatementOptmEarlyAbortReason); + // CE model, then Optimization. #613: the early abort reason is part of the optimization + // result, not a fact of its own, so it sits under the Optimization row. if (statement.CardinalityEstimationModelVersion > 0) AddRow("CE model", statement.CardinalityEstimationModelVersion.ToString()); + var hasOptimizationRow = !string.IsNullOrEmpty(statement.StatementOptmLevel); + if (hasOptimizationRow) + AddRow("Optimization", statement.StatementOptmLevel!); + if (!string.IsNullOrEmpty(statement.StatementOptmEarlyAbortReason)) + AddRow("Early abort", statement.StatementOptmEarlyAbortReason, nested: hasOptimizationRow); if (grid.Children.Count > 0) { diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs index 7e24580d..c74f1138 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.axaml.cs @@ -285,6 +285,20 @@ public ServerMetadata? Metadata /// public string? ConnectionString { get; set; } + /// + /// The database this plan came from, when that is a Query Store grid's own database picker + /// rather than the session's toolbar — null for every other plan tab (executed, pasted, or + /// opened from History), which all keep using the toolbar's database. A grid's picker is + /// independent of the toolbar's, so a plan pulled from one can be sitting on a different + /// database than DatabaseBox shows; Get Actual Plan (QuerySessionControl.Execution.cs) reads + /// this back to re-run such a plan against the database it actually came from, instead of + /// silently switching it to whatever the toolbar happens to show (E1). Set once, in + /// QuerySessionControl.AddPlanTab, from the grid's Database at the moment the plan was + /// opened — not kept in sync with the grid afterward, the same way + /// is a snapshot rather than a live link back to the session. + /// + public string? SourceDatabase { get; set; } + /// /// Whether this viewer is living as a sub-tab inside a query session, rather than as a /// top-level tab of its own. diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Advice.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Advice.cs index 0c706ba5..eda94324 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Advice.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Advice.cs @@ -60,7 +60,9 @@ private void RobotAdvice_Click(object? sender, RoutedEventArgs e) process down with no dialog and nothing logged. AnalysisJson's depth ceiling makes this unreachable for any plan seen in the field — this catch is here so that "unreachable" is not the only thing standing between a deep plan and a silent crash. */ - SetErrorStatus($"Could not build robot advice for this plan: {ex.Message}"); + SetErrorStatus(ex is JsonException + ? $"Could not build robot advice for this plan. {AnalysisJson.TooDeepMessage}" + : $"Could not build robot advice for this plan: {ex.Message}"); return; } diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs index 4512eba6..d081ea9f 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs @@ -47,6 +47,21 @@ private async Task ShowConnectionDialogAsync() _selectedDatabase = dialog.ResultDatabase; _connectionString = _serverConnection.GetConnectionString(_credentialService, _selectedDatabase); + /* A new holder for a new connection, made here rather than at the fetch below so a + document opened while the fetch is still in flight already has the right one to + read, and handed to the fetch as a parameter so an offset that lands after ANOTHER + reconnect fills this connection's holder and not the newer one's (E5). The + connection string is captured for the same reason. */ + var serverOffset = BeginServerConnection(); + var offsetConnectionString = _connectionString; + + /* A database metadata fetch still running from before this reconnect was built from + the previous server's connection string. Left alone it can land after + FetchServerMetadataAsync below has replaced _serverMetadata, and write the old + server's database rows into the new server's metadata (E7). The fetch this block + makes itself is the newest pick and owns the result. */ + _databaseMetadataCts?.Cancel(); + ServerLabel.Text = _serverConnection.ApplicationIntentReadOnly ? $"{_serverConnection.ServerName} (Read-only)" : _serverConnection.ServerName; @@ -74,7 +89,7 @@ whose swallowed failure left a green toolbar over a dead database picker. */ DatabaseBox.IsEnabled = true; await FetchServerMetadataAsync(); - await FetchServerUtcOffset(); + await FetchServerUtcOffset(offsetConnectionString, serverOffset); if (_selectedDatabase != null) { @@ -112,6 +127,31 @@ private async void Database_SelectionChanged(object? sender, SelectionChangedEve await FetchDatabaseMetadataAsync(); } + /// + /// Selects in the database picker if it is one of the databases the + /// picker already knows about, using the same match the connect block above uses to restore + /// a remembered database. Selecting it runs exactly + /// as a user's own pick would, which is what actually sets _selectedDatabase and + /// _connectionString and refreshes the metadata — so this never writes either field + /// itself. Returns whether a match was found; the Overview's drill-down + /// (QuerySessionControl.Views.cs) opens its Query Store tab either way, so a database the + /// picker does not know about — one created after connect, most likely — just leaves the + /// session on whatever database it already had (E1). + /// + internal bool TrySelectDrilledDatabase(string db) + { + for (int i = 0; i < DatabaseBox.Items.Count; i++) + { + if (DatabaseBox.Items[i]?.ToString() == db) + { + DatabaseBox.SelectedIndex = i; + return true; + } + } + + return false; + } + private async Task FetchServerMetadataAsync() { if (_connectionString == null) return; @@ -127,18 +167,36 @@ private async Task FetchServerMetadataAsync() } } - private async Task FetchServerUtcOffset() + /// + /// Starts the offset holder for a new connection. A connect replaces the holder instead of + /// reusing it: documents already open keep the old one, because their data came from the old + /// server, and documents opened from here on get this one. Internal so a test can connect + /// without a server — the connect block calls this and nothing else makes a holder (E5). + /// + internal ServerUtcOffset BeginServerConnection() { - if (_connectionString == null) return; + _serverOffset = new ServerUtcOffset(); + return _serverOffset; + } + + /// + /// Asks the server it just connected to for its offset from UTC and fills + /// with it. Takes the connection string and the holder rather than + /// reading the session's fields, so the answer lands on the connection that asked even if the + /// session has reconnected in the meantime. A failed query leaves the holder at zero. + /// + private static async Task FetchServerUtcOffset(string? connectionString, ServerUtcOffset target) + { + if (connectionString == null) return; try { - await using var conn = new SqlConnection(_connectionString); + await using var conn = new SqlConnection(connectionString); await conn.OpenAsync(); await using var cmd = new SqlCommand( "SELECT DATEDIFF(MINUTE, GETUTCDATE(), GETDATE())", conn); var offset = await cmd.ExecuteScalarAsync(); if (offset is int mins) - PlanViewer.Core.Services.TimeDisplayHelper.ServerUtcOffsetMinutes = mins; + target.Minutes = mins; } catch { } } @@ -146,10 +204,28 @@ private async Task FetchServerUtcOffset() private async Task FetchDatabaseMetadataAsync() { if (_connectionString == null || _serverMetadata == null) return; + + /* The database picker can change again before this lands — Database_SelectionChanged + calls this on every pick, and nothing stopped an older fetch from landing after a + newer one and overwriting _serverMetadata.Database with the wrong database's rows + (E7). Same shape as the Query Store grid's own database check + (QueryStoreGridControl.QsDatabase_SelectionChanged). Cancelled, never disposed: the + older fetch reads its own token when it wakes, and Token on a disposed source throws. */ + _databaseMetadataCts?.Cancel(); + var cts = new CancellationTokenSource(); + _databaseMetadataCts = cts; + try { - _serverMetadata.Database = await ServerMetadataService.FetchDatabaseMetadataAsync( - _connectionString, _serverMetadata.SupportsScopedConfigs); + var database = await ServerMetadataService.FetchDatabaseMetadataAsync( + _connectionString, _serverMetadata.SupportsScopedConfigs, cts.Token); + if (cts.Token.IsCancellationRequested) return; // superseded — the newer pick owns this + + _serverMetadata.Database = database; + } + catch (OperationCanceledException) + { + // superseded — the newer pick owns this } catch { diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Documents.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Documents.cs index bb28ad33..dabb7bda 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Documents.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Documents.cs @@ -125,6 +125,14 @@ private void MakeStripDeselectable() private void RemoveDocument(TabItem tab) { var index = SubTabControl.Items.IndexOf(tab); + + /* Already gone, and nothing to land anywhere: closing a loading tab cancels its run, and + the run's own cancelled continuation removes its tab as well, so the second removal + arrives for a tab the first one took out. Without this it would run the landing rules + below for a removal that changed nothing. */ + if (index < 0) + return; + SubTabControl.Items.Remove(tab); if (_surface != SessionSurface.Documents) diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs index 85b77b2d..8cce2735 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Editor.cs @@ -250,10 +250,15 @@ slots that starts off the right-hand end of it. Every other button in that half Format_Click(this, new RoutedEventArgs()); e.Handled = true; } - // Escape → Cancel running query - else if (e.Key == Key.Escape && _executionCts != null && !_executionCts.IsCancellationRequested) + /* Escape → Cancel the running query, but only where that query is: in the editor it was + started from, or on the tab that is showing it. This handler hears every Escape in the + session, and cancelling the current run for all of them meant a keystroke on a finished + plan, a Query Store grid or the Overview reached past its own tab and stopped a capture + running somewhere else. */ + else if (e.Key == Key.Escape && EscapeBelongsToCurrentRun() + && _executionCts is { IsCancellationRequested: false } run) { - _executionCts.Cancel(); + run.Cancel(); e.Handled = true; } /* Ctrl+F4 → close the document being looked at. diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs index 71bb358f..18f6ccdf 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs @@ -40,6 +40,35 @@ private async void ExecuteEstimated_Click(object? sender, RoutedEventArgs e) await CaptureAndShowPlan(estimated: true); } + /// + /// Starts a run: cancels the one before it, and hands back the source the new one runs on. + /// + /// + /// Cancelled, never disposed. The loading tab a run opens keeps a Cancel button and an Escape + /// handler that close over this source, and they outlive the run — a failed capture leaves its + /// tab on screen. Cancel on a disposed source throws, so a later run disposing this one would + /// turn that tab's Escape into an exception. Same rule as + /// and the Overview's refresh. + /// + private CancellationTokenSource BeginRun() + { + _executionCts?.Cancel(); + var run = new CancellationTokenSource(); + _executionCts = run; + return run; + } + + /// + /// Whether an Escape pressed right now is about the session's current run: the editor is what + /// is showing, which is where the run was started from, or the document showing is the tab + /// that owns it. Escape anywhere else belongs to whatever is there. + /// + private bool EscapeBelongsToCurrentRun() => + _surface == SessionSurface.Editor + || (SelectedDocument is { } document + && _tabRuns.TryGetValue(document, out var run) + && ReferenceEquals(run, _executionCts)); + private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride = null) { if (_serverConnection == null || _selectedDatabase == null) @@ -61,10 +90,8 @@ private async Task CaptureAndShowPlan(bool estimated, string? queryTextOverride return; } - _executionCts?.Cancel(); - _executionCts?.Dispose(); - _executionCts = new CancellationTokenSource(); - var ct = _executionCts.Token; + var runCts = BeginRun(); + var ct = runCts.Token; var planType = estimated ? "Estimated" : "Actual"; @@ -111,7 +138,7 @@ failure is reported. A SQL error is the one string in this app a user most needs VerticalContentAlignment = VerticalAlignment.Center, Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! }; - cancelBtn.Click += (_, _) => _executionCts?.Cancel(); + cancelBtn.Click += (_, _) => runCts.Cancel(); loadingPanel.Children.Add(progressBar); loadingPanel.Children.Add(statusLabel); @@ -123,15 +150,19 @@ failure is reported. A SQL error is the one string in this app a user most needs Focusable = true, Children = { loadingPanel } }; + /* This run's own source, not the session's current one. The handler outlives the run: a + failed capture leaves this container on screen, still focusable, and Escape pressed on + it later must not reach past this tab and cancel whatever run is newer. */ loadingContainer.KeyDown += (_, ke) => { - if (ke.Key == Key.Escape) { _executionCts?.Cancel(); ke.Handled = true; } + if (ke.Key == Key.Escape) { runCts.Cancel(); ke.Handled = true; } }; // Add loading tab and switch to it _planCounter++; var tabLabel = estimated ? $"Est Plan {_planCounter}" : $"Plan {_planCounter}"; var loadingTab = NewPlanTab(tabLabel, loadingContainer); + _tabRuns.AddOrUpdate(loadingTab, runCts); AddDocument(loadingTab); SelectDocument(loadingTab); @@ -162,6 +193,12 @@ failure is reported. A SQL error is the one string in this app a user most needs sw.Stop(); + /* The result can arrive after the run was cancelled: the tab was closed, or the next + query superseded this one, while the answer was already on its way back. Showing it + would put a plan viewer into a tab that is no longer in the strip, and it would stay + registered with the MCP session manager with nothing left to close it. */ + ct.ThrowIfCancellationRequested(); + if (string.IsNullOrEmpty(planXml)) { statusLabel.Text = $"No plan returned ({sw.Elapsed.TotalSeconds:F1}s)"; @@ -208,16 +245,14 @@ strip clears it instead and RemoveDocument picks the neighbour afterwards — a /// needing a SQL Server to produce some. The half of these paths that reaches out to a server /// is above this; everything that decides what the user ends up looking at is here. /// - internal void ShowCapturedPlan(TabItem planTab, string planXml, string tabLabel, string queryText) + internal void ShowCapturedPlan(TabItem planTab, string planXml, string tabLabel, string queryText, + string? sourceDatabase = null) { var viewer = new PlanViewerControl(); // Sub-tab of this session: the session's toolbar above it owns the connection (#U5). viewer.HostedInSession = true; viewer.Metadata = _serverMetadata; - viewer.ConnectionString = _connectionString; - viewer.SetConnectionServices(_credentialService, _connectionStore); - if (_serverConnection != null) - viewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase); + ConnectViewer(viewer, sourceDatabase); viewer.OpenInEditorRequested += OnOpenInEditorRequested; viewer.LoadPlan(planXml, tabLabel, queryText); planTab.Content = viewer; @@ -266,6 +301,27 @@ internal static void ShowExecutionFailure( cancelBtn.IsVisible = false; } + /// + /// Resolves which database and connection string Get Actual Plan should run + /// 's query against: the viewer's own when it has one — a plan pulled from a Query + /// Store grid's own database picker, which can differ from the toolbar's (E1) — or the + /// toolbar's _selectedDatabase/_connectionString when it does not, which is + /// every other plan tab (executed, pasted, or opened from History). + /// + /// Internal so a test can pin the choice directly — through and this method — rather than by executing a + /// query to observe which database it landed in. + /// + internal (string? Database, string? ConnectionString) ResolveExecutionTarget(PlanViewerControl viewer) + { + if (viewer.SourceDatabase != null) + return (viewer.SourceDatabase, + _serverConnection?.GetConnectionString(_credentialService, viewer.SourceDatabase)); + + return (_selectedDatabase, _connectionString); + } + private async void GetActualPlan_Click(object? sender, RoutedEventArgs e) { var viewer = GetSelectedPlanViewer(); @@ -290,17 +346,22 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e) return; } + var (database, connectionString) = ResolveExecutionTarget(viewer); + if (connectionString == null) + { + SetErrorStatus("Connect to a server first"); + return; + } + /* Show confirmation dialog */ var confirmed = await ShowConfirmationDialog( "Get Actual Plan", - "The query will execute with SET STATISTICS XML ON to capture the actual plan.\n\nAll data results will be discarded.\n\nContinue?"); + $"The query will execute in [{database}] with SET STATISTICS XML ON to capture the actual plan.\n\nAll data results will be discarded.\n\nContinue?"); if (!confirmed) return; - _executionCts?.Cancel(); - _executionCts?.Dispose(); - _executionCts = new CancellationTokenSource(); - var ct = _executionCts.Token; + var runCts = BeginRun(); + var ct = runCts.Token; // Create loading tab with cancel button var loadingPanel = new StackPanel @@ -343,7 +404,7 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e) VerticalContentAlignment = VerticalAlignment.Center, Theme = (Avalonia.Styling.ControlTheme)this.FindResource("AppButton")! }; - cancelBtn.Click += (_, _) => _executionCts?.Cancel(); + cancelBtn.Click += (_, _) => runCts.Cancel(); loadingPanel.Children.Add(progressBar); loadingPanel.Children.Add(statusLabel); @@ -355,14 +416,18 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e) Focusable = true, Children = { loadingPanel } }; + /* This run's own source, not the session's current one. The handler outlives the run: a + failed capture leaves this container on screen, still focusable, and Escape pressed on + it later must not reach past this tab and cancel whatever run is newer. */ loadingContainer.KeyDown += (_, ke) => { - if (ke.Key == Key.Escape) { _executionCts?.Cancel(); ke.Handled = true; } + if (ke.Key == Key.Escape) { runCts.Cancel(); ke.Handled = true; } }; _planCounter++; var tabLabel = $"Plan {_planCounter}"; var loadingTab = NewPlanTab(tabLabel, loadingContainer); + _tabRuns.AddOrUpdate(loadingTab, runCts); AddDocument(loadingTab); SelectDocument(loadingTab); @@ -374,12 +439,15 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e) var isAzure = IsAzureConnection; var actualPlanXml = await ActualPlanExecutor.ExecuteForActualPlanAsync( - _connectionString, _selectedDatabase, queryText, + connectionString, database, queryText, planXml, isolationLevel: null, isAzureSqlDb: isAzure, timeoutSeconds: 0, ct); sw.Stop(); + // Same as the capture path above: a cancelled run does not put its plan on screen. + ct.ThrowIfCancellationRequested(); + if (string.IsNullOrEmpty(actualPlanXml)) { statusLabel.Text = $"No actual plan returned ({sw.Elapsed.TotalSeconds:F1}s)"; @@ -389,7 +457,11 @@ private async void GetActualPlan_Click(object? sender, RoutedEventArgs e) } SetStatus($"Actual plan captured ({sw.Elapsed.TotalSeconds:F1}s)"); - ShowCapturedPlan(loadingTab, actualPlanXml, tabLabel, queryText); + + // Carry the source database forward, so a second Get Actual Plan on THIS tab (the + // one just captured) still runs where the first one did rather than reverting to + // the toolbar's (E1). Null for a toolbar-sourced plan, same as its own viewer. + ShowCapturedPlan(loadingTab, actualPlanXml, tabLabel, queryText, viewer.SourceDatabase); } catch (OperationCanceledException) { @@ -427,7 +499,7 @@ private Task ShowConfirmationDialog(string title, string message, string c try { - var doc = XDocument.Parse(planXml); + var doc = PlanXml.Parse(planXml); XNamespace ns = "http://schemas.microsoft.com/sqlserver/2004/07/showplan"; /* Try StmtSimple first — most queries have this */ diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs index 2fbd2fd5..5b77f749 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs @@ -33,9 +33,36 @@ namespace PlanViewer.App.Controls; public partial class QuerySessionControl : UserControl { private bool AddPlanTab(string planXml, string queryText, bool estimated, string? labelOverride = null) - => AddPlanTab(planXml, queryText, estimated, labelOverride, out _); + => AddPlanTab(planXml, queryText, estimated, labelOverride, sourceDatabase: null, out _); private bool AddPlanTab(string planXml, string queryText, bool estimated, string? labelOverride, out string? failure) + => AddPlanTab(planXml, queryText, estimated, labelOverride, sourceDatabase: null, out failure); + + /// + /// Points a plan viewer hosted in this session at the database its plan came from: + /// when a Query Store grid supplied one, the toolbar's when + /// not. The viewer's schema lookups (Show Indexes, Show Table Definition) run on its + /// ConnectionString and its status label names the database, so both follow the plan rather + /// than whatever the toolbar shows (E1). + /// + private void ConnectViewer(PlanViewerControl viewer, string? sourceDatabase) + { + viewer.SourceDatabase = sourceDatabase; + viewer.ConnectionString = sourceDatabase != null && _serverConnection != null + ? _serverConnection.GetConnectionString(_credentialService, sourceDatabase) + : _connectionString; + viewer.SetConnectionServices(_credentialService, _connectionStore); + if (_serverConnection != null) + viewer.SetConnectionStatus(_serverConnection.ServerName, sourceDatabase ?? _selectedDatabase); + } + + /// + /// The database this plan came from, when that is a Query Store grid's own picker rather + /// than the toolbar's — null for every other path. See and + /// for why (E1). + /// + private bool AddPlanTab(string planXml, string queryText, bool estimated, string? labelOverride, + string? sourceDatabase, out string? failure) { failure = null; _planCounter++; @@ -45,10 +72,7 @@ private bool AddPlanTab(string planXml, string queryText, bool estimated, string // Sub-tab of this session: the session's toolbar above it owns the connection (#U5). viewer.HostedInSession = true; viewer.Metadata = _serverMetadata; - viewer.ConnectionString = _connectionString; - viewer.SetConnectionServices(_credentialService, _connectionStore); - if (_serverConnection != null) - viewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase); + ConnectViewer(viewer, sourceDatabase); viewer.OpenInEditorRequested += OnOpenInEditorRequested; if (!viewer.LoadPlan(planXml, label, queryText)) @@ -201,18 +225,82 @@ void CommitRename() /// Said once because there are four doors onto it — the header's ✕, the context menu's Close, /// its two bulk siblings, and Ctrl+F4 — and the release half is the half that goes missing. /// A plan viewer holds an MCP session registration that nothing else unregisters, so a close - /// that only removes the tab leaks it, invisibly and for the life of the process. The sub-tab - /// kinds built by release themselves instead, on detach, which is - /// what removing them from the strip causes. + /// that only removes the tab leaks it, invisibly and for the life of the process. The ✕ that + /// builds for Query Store and History documents goes through here + /// too, so what each kind gives up is decided in and nowhere else. /// private void CloseDocument(TabItem tab) { - if (tab.Content is PlanViewerControl viewer) - viewer.Clear(); - + ReleaseDocument(tab); RemoveDocument(tab); } + /// + /// Gives up what one document holds, and leaves it in the strip. + /// + /// + /// Said once so that closing one document and closing the whole session release the same + /// things: calls it for the document it is closing, and + /// calls it for every document the session still holds. It reads + /// the tab's content as it is NOW, so a tab that started as a spinner and had a plan swapped + /// into it is released as the plan viewer it became. + /// + /// What each kind holds: a plan viewer holds its MCP session registration, a Query Store + /// grid and a History document hold a fetch running on the server, and a loading tab holds the + /// run that will fill it. Closing the tab stops all of them, because none of them has anywhere + /// left to put what it was fetching. + /// + /// Safe to call twice on the same document: unregistering a plan that is already gone, + /// and cancelling a source that is already cancelled, both do nothing. + /// + private void ReleaseDocument(TabItem tab) + { + switch (tab.Content) + { + case PlanViewerControl viewer: + viewer.Clear(); + break; + + case QueryStoreGridControl grid: + grid.CancelFetch(); + break; + + case QueryStoreHistoryControl history: + history.CancelFetch(); + break; + } + + if (_tabRuns.TryGetValue(tab, out var run)) + run.Cancel(); + } + + /// + /// The session's tab has been closed for good: releases every document it still holds, and + /// stops the work the session itself has running. + /// + /// + /// Called by the window that owns the tab, after the tab has left the strip or the detached + /// window has closed. Not called on a detach or a re-dock, where the session moves and stays + /// open. Closing a session used to remove its tab and nothing else: every plan viewer in it + /// stayed registered with the MCP session manager until the app exited, and a query or plan + /// capture (both run with no timeout) kept running on the server for a session nobody could + /// see any more. + /// + /// The current run is cancelled here directly, as well as through the loading tab that + /// shows it: the session's current source is the one thing a close must never leave live, + /// whatever state its tab is in. Starting a run cancels the one before it, so it is also the + /// only source that can still be live. The metadata fetch behind the database picker belongs + /// to the session too. + /// + internal void ReleaseOnClose() + { + _executionCts?.Cancel(); + _databaseMetadataCts?.Cancel(); + + foreach (var tab in DocumentTabs.ToList()) + ReleaseDocument(tab); + } + private void ClosePlanTab_Click(object? sender, RoutedEventArgs e) { if (sender is Button btn && btn.Tag is TabItem tab) diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs b/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs index 2ce5c549..81e48a11 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs @@ -41,9 +41,10 @@ private bool HasQueryStoreTab() /// /// Creates a sub-tab with a standard header (label + optional extra buttons + close button). - /// Returns the TabItem. The close button removes the tab from the document strip. + /// Returns the TabItem. The close button closes the document: it gives up what the content + /// holds (see ) and takes the tab out of the strip. /// - private TabItem CreateSubTab(string label, Control content, Action? onClose = null, params Button[] extraButtons) + private TabItem CreateSubTab(string label, Control content, params Button[] extraButtons) { var headerText = new TextBlock { @@ -82,10 +83,7 @@ private TabItem CreateSubTab(string label, Control content, Action? onC closeBtn.Click += (s, _) => { if (s is Button btn && btn.Tag is TabItem t) - { - onClose?.Invoke(t); - RemoveDocument(t); - } + CloseDocument(t); }; return tab; @@ -148,13 +146,36 @@ private async Task OpenQueryStoreForDatabaseAsync(string database, DateTime? ini var databases = DatabaseBox.Items.OfType().ToList(); - var grid = new QueryStoreGridControl(_serverConnection!, _credentialService, - database, databases, supportsWaitStats); + var grid = NewQueryStoreGrid(database, databases, supportsWaitStats); if (initialStartUtc.HasValue && initialEndUtc.HasValue) grid.SetInitialTimeRange(initialStartUtc.Value, initialEndUtc.Value); + + AddQueryStoreDocument(grid, database); + } + + /// + /// Builds a Query Store grid on the connection the session is on now, which is what gives it + /// that connection's offset holder to keep (E5). Said once because the toolbar's Query Store + /// button and the Overview's drill-down both build one, and a second copy of this line is a + /// second place to forget the holder. Internal so a test can build one without the server + /// check that comes before it in both callers. + /// + internal QueryStoreGridControl NewQueryStoreGrid(string database, List databases, bool supportsWaitStats) => + new(_serverConnection!, _credentialService, _serverOffset, database, databases, supportsWaitStats); + + /// + /// Puts a Query Store grid into the strip as a document and shows it. + /// + /// Said once because the toolbar's Query Store button and the Overview's drill-down both + /// open one, and the two used to carry the same ten lines. Internal so a test can hand it a grid + /// it built itself: the paths above fetch from a server before they get this far. + /// + internal void AddQueryStoreDocument(QueryStoreGridControl grid, string database) + { grid.PlansSelected += OnQueryStorePlansSelected; var tab = CreateSubTab($"Query Store — {database}", grid); + // Update tab header when database is changed via the grid's picker grid.DatabaseChanged += (_, db) => { if (GetSubTabHeaderText(tab) is TextBlock tb) @@ -219,20 +240,9 @@ family as #448. */ // Build database list from the current DatabaseBox var databases = DatabaseBox.Items.OfType().ToList(); - var grid = new QueryStoreGridControl(_serverConnection!, _credentialService, - _selectedDatabase!, databases, supportsWaitStats); - grid.PlansSelected += OnQueryStorePlansSelected; - - var tab = CreateSubTab($"Query Store — {_selectedDatabase}", grid); - // Update tab header when database is changed via the grid's picker - grid.DatabaseChanged += (_, db) => - { - if (GetSubTabHeaderText(tab) is TextBlock tb) - tb.Text = $"Query Store — {db}"; - }; + var grid = NewQueryStoreGrid(_selectedDatabase!, databases, supportsWaitStats); - AddDocument(tab); - SelectDocument(tab); + AddQueryStoreDocument(grid, _selectedDatabase!); } /// @@ -244,12 +254,18 @@ family as #448. */ /// internal void OnQueryStorePlansSelected(object? sender, List plans) { + /* The grid has its own database picker, independent of the toolbar's (E1) — remembered + here, off the sender, so Get Actual Plan can later run a plan from this batch back + against the database it actually came from rather than whatever the toolbar shows. */ + var sourceDatabase = (sender as QueryStoreGridControl)?.Database; + int loaded = 0; var failures = new List(); foreach (var qsPlan in plans) { var tabLabel = $"QS {qsPlan.QueryId} / {qsPlan.PlanId}"; - if (AddPlanTab(qsPlan.PlanXml, qsPlan.QueryText, estimated: true, labelOverride: tabLabel, out var failure)) + if (AddPlanTab(qsPlan.PlanXml, qsPlan.QueryText, estimated: true, labelOverride: tabLabel, + sourceDatabase: sourceDatabase, out var failure)) loaded++; else if (failure != null) failures.Add(failure); @@ -297,9 +313,7 @@ public void AddHistorySubTab(string label, QueryStoreHistoryControl control) }; Avalonia.Controls.ToolTip.SetTip(detachBtn, "Detach to Window"); - var tab = CreateSubTab(label, control, - onClose: t => { if (t.Content is QueryStoreHistoryControl hc) hc.CancelFetch(); }, - detachBtn); + var tab = CreateSubTab(label, control, detachBtn); detachBtn.Tag = tab; detachBtn.Click += (s, _) => diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Views.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Views.cs index 10db6db7..f14f7234 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Views.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Views.cs @@ -186,14 +186,21 @@ already reports itself. */ private QueryStoreOverviewControl BuildOverviewView() { var supportsWaitStats = _serverMetadata?.SupportsQueryStoreWaitStats ?? false; - var overview = new QueryStoreOverviewControl(_serverConnection!, _credentialService, + var overview = new QueryStoreOverviewControl(_serverConnection!, _credentialService, _serverOffset, supportsWaitStats: supportsWaitStats); overview.DrillDownRequested += async (_, args) => { - // Open a single-database Query Store tab directly (no connection dialog) - _selectedDatabase = args.Database; - _connectionString = _serverConnection!.GetConnectionString(_credentialService, args.Database); + /* The drill-down means switch this session to that database, not just open a tab + against it — going through the picker keeps DatabaseBox, _selectedDatabase and + _connectionString in agreement, instead of writing the last two here directly and + leaving the toolbar showing whatever database the session was on before (E1). + TrySelectDrilledDatabase runs Database_SelectionChanged exactly as a user's own + pick would, which is what actually sets both fields and refreshes the metadata. */ + TrySelectDrilledDatabase(args.Database); + + // Found or not, the Query Store tab opens on the drilled database either way — it + // builds its own connection string from args.Database directly. await OpenQueryStoreForDatabaseAsync(args.Database, args.StartUtc, args.EndUtc); }; diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index 03d21a8b..1ee9b7bc 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; @@ -104,9 +105,36 @@ public void MarkClean() private ServerConnection? _serverConnection; private string? _connectionString; private string? _selectedDatabase; + + /// + /// The offset from UTC to the server this session is connected to, for Server time display + /// (E5). Every connect replaces it (), and every document + /// opened on a connection keeps the one that connection had, so a reconnect — or another + /// session on a server in a different time zone — cannot move times that are already on + /// screen. Never null: a session that has not connected holds an empty one, which is zero. + /// + private ServerUtcOffset _serverOffset = new(); + private int _planCounter; + /// + /// The run in flight, or the last one that was: the source of the query or plan capture the + /// session started most recently. Starting another run cancels this one, so it is the only + /// source that can still be live. Read by the editor's Escape and by closing the session. + /// private CancellationTokenSource? _executionCts; + + /// + /// Which run each loading tab is showing, so closing the tab can stop the run it owns. + /// Weak, so a tab that is closed and forgotten takes its entry with it. + /// + private readonly ConditionalWeakTable _tabRuns = new(); private ServerMetadata? _serverMetadata; + /// + /// Guards FetchDatabaseMetadataAsync's own stale-result race: the database picker can change + /// again before a fetch lands, and without this whichever fetch finished last used to + /// overwrite _serverMetadata.Database, even for a database already clicked past (E7). + /// + private CancellationTokenSource? _databaseMetadataCts; // TextMate installation for syntax highlighting private TextMate.Installation? _textMateInstallation; diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs index e31ecd6a..8ced2934 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.Fetch.cs @@ -24,8 +24,41 @@ namespace PlanViewer.App.Controls; public partial class QueryStoreGridControl : UserControl { + /// + /// Set once the grid's tab has been closed. One-way: nothing re-docks a grid, so a grid that + /// has been told to stop is finished, and no later fetch may start on it. + /// + private bool _abandoned; + + /// + /// Stops everything this grid has running against the server, and keeps it from starting more. + /// Called when its tab closes: without it the fetch kept running on the server, for a grid + /// nobody could see, until it finished by itself. The History document does the same when it + /// closes. + /// + /// + /// Cancelled, never disposed — the opposite of History's, on purpose. Every fetch here reads + /// its own token when it wakes, and reads + /// _fetchCts.Token fresh at the moment it runs; Token on a disposed source throws, and + /// nothing in those callers is catching it. A cancelled source hands out a cancelled token, + /// which is exactly what a caller arriving late should get. + /// + /// The latch is for the fetch that has not started yet: the constructor posts the first + /// fetch to run after layout, and a tab closed before that lands would otherwise start it on a + /// grid that is already gone. + /// + public void CancelFetch() + { + _abandoned = true; + _fetchCts?.Cancel(); + _databaseCheckCts?.Cancel(); + } + private async void Fetch_Click(object? sender, RoutedEventArgs e) { + if (_abandoned) + return; + // Commit any pending toolbar "Search by" entry into the server-filter state first, // then refresh the chip strip, before running the fetch. CommitSearchByCriterion(); @@ -136,7 +169,7 @@ private async System.Threading.Tasks.Task FetchFlatPlansAsync( } foreach (var plan in plans) - _rows.Add(new QueryStoreRow(plan)); + _rows.Add(new QueryStoreRow(plan, _serverOffset)); ApplyFilters(); LoadButton.IsEnabled = true; @@ -344,7 +377,7 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped) { var leafPlan = GroupedRowToPlan(leaf); leafChildren.Add(new QueryStoreRow(leafPlan, 2, - $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List())); + $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List(), _serverOffset)); } // Sort leaf children by metric descending @@ -355,7 +388,7 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped) var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault(); if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText)) midPlan.QueryText = topLeafForMid.QueryText; - midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryPlanHash, leafChildren)); + midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryPlanHash, leafChildren, _serverOffset)); } // Sort mid children by metric descending @@ -370,7 +403,7 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped) ?? grouped.LeafRows.FirstOrDefault(l => l.QueryHash == qhKey && !string.IsNullOrEmpty(l.QueryText)); if (topLeafForRoot != null) aggPlan.QueryText = topLeafForRoot.QueryText; - roots.Add(new QueryStoreRow(aggPlan, 0, qhKey, midChildren)); + roots.Add(new QueryStoreRow(aggPlan, 0, qhKey, midChildren, _serverOffset)); } } else // Module @@ -398,7 +431,7 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped) { var leafPlan = GroupedRowToPlan(leaf); leafChildren.Add(new QueryStoreRow(leafPlan, 2, - $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List())); + $"Q:{leaf.QueryId} P:{leaf.PlanId}{(leaf.IsTopRepresentative ? " ★" : "")}", new List(), _serverOffset)); } // Sort leaf children by metric descending @@ -409,7 +442,7 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped) var topLeafForMid = leaves.FirstOrDefault(l => l.IsTopRepresentative) ?? leaves.FirstOrDefault(); if (topLeafForMid != null && !string.IsNullOrEmpty(topLeafForMid.QueryText)) midPlan.QueryText = topLeafForMid.QueryText; - midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryHash, leafChildren)); + midChildren.Add(new QueryStoreRow(midPlan, 1, mid.QueryHash, leafChildren, _serverOffset)); } // Sort mid children by metric descending @@ -424,7 +457,7 @@ private List BuildGroupedRows(QueryStoreGroupedResult grouped) ?? grouped.LeafRows.FirstOrDefault(l => l.ModuleName == modKey && !string.IsNullOrEmpty(l.QueryText)); if (topLeafForRoot != null) aggPlan.QueryText = topLeafForRoot.QueryText; - roots.Add(new QueryStoreRow(aggPlan, 0, modKey, midChildren)); + roots.Add(new QueryStoreRow(aggPlan, 0, modKey, midChildren, _serverOffset)); } } diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs index 5ab5d115..4ca2bc8a 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.Selection.cs @@ -94,11 +94,14 @@ private void ViewHistory_Click(object? sender, RoutedEventArgs e) var metricTag = QueryStoreHistoryWindow.MapOrderByToMetricTag(_lastFetchedOrderBy); + /* The grid's own holder, not the session's current one: this History reads the grid's + server, and the session may have reconnected somewhere else since the grid opened (E5). */ var control = new QueryStoreHistoryControl( _connectionString, row.QueryHash, row.FullQueryText, _database, + _serverOffset, initialMetricTag: metricTag, slicerStartUtc: _slicerStartUtc, slicerEndUtc: _slicerEndUtc, @@ -120,6 +123,7 @@ private void ViewHistory_Click(object? sender, RoutedEventArgs e) row.QueryHash, row.FullQueryText, _database, + _serverOffset, initialMetricTag: metricTag, slicerStartUtc: _slicerStartUtc, slicerEndUtc: _slicerEndUtc, diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.ServerFilters.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.ServerFilters.cs index 7e57e297..dcaf75da 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.ServerFilters.cs +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.ServerFilters.cs @@ -80,10 +80,19 @@ private void RebuildChips() ChipStrip.IsVisible = chips.Count > 0; } - /// Persists the panel's expanded/collapsed state whenever the user toggles it. + /// + /// Persists the panel's expanded/collapsed state whenever the user toggles it. + /// + /// Mutates the live cached instance and saves it in place, the same way MainWindow + /// itself persists AppSettings elsewhere (SaveOpenPlans, AddRecentPlan, ...). This used to + /// Clone() first, which handed AppSettingsService a second, independent AppSettings object: + /// it cached that clone, but MainWindow kept its own older _appSettings reference, and + /// MainWindow's next save — closing a tab, opening a plan — wrote that older object straight + /// back over the clone, silently reverting the panel's expanded state. + /// private void ServerFilterExpander_StateChanged(object? sender, RoutedEventArgs e) { - var s = AppSettingsService.Load().Clone(); + var s = AppSettingsService.Load(); s.QueryStoreFilterPanelExpanded = ServerFilterExpander.IsExpanded; AppSettingsService.Save(s); } diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.Sort.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.Sort.cs index 68de6e53..a25a5ed9 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.Sort.cs +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.Sort.cs @@ -98,6 +98,8 @@ private void TimeDisplay_SelectionChanged(object? sender, SelectionChangedEventA } // Refresh slicer labels TimeRangeSlicer.Redraw(); + // Refresh the wait ribbon's labels and tips, which kept the old mode until a resize + WaitStatsProfile.RedrawRibbon(); } private void UpdateStatusText() diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs index 7ac00c06..758e08d2 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs @@ -24,9 +24,21 @@ public partial class QueryStoreGridControl : UserControl { private readonly ServerConnection _serverConnection; private readonly ICredentialService _credentialService; + /// + /// The offset holder of the connection this grid was opened on (E5). Kept for the grid's whole + /// life and handed to everything it builds — rows, slicer, ribbon, History — because they show + /// this server's data, whichever connection the session is on by the time they draw. + /// + private readonly ServerUtcOffset _serverOffset; private string _connectionString; private string _database; private CancellationTokenSource? _fetchCts; + /// + /// Guards the picker's own CheckEnabledAsync race: two selections before the first check + /// lands used to let whichever finished last win, even for a database the user had already + /// clicked past. See (E6). + /// + private CancellationTokenSource? _databaseCheckCts; private ObservableCollection _rows = new(); private ObservableCollection _filteredRows = new(); private readonly Dictionary _activeFilters = new(); @@ -56,10 +68,11 @@ public partial class QueryStoreGridControl : UserControl public string Database => _database; public QueryStoreGridControl(ServerConnection serverConnection, ICredentialService credentialService, - string initialDatabase, List databases, bool supportsWaitStats = false) + ServerUtcOffset serverOffset, string initialDatabase, List databases, bool supportsWaitStats = false) { _serverConnection = serverConnection; _credentialService = credentialService; + _serverOffset = serverOffset; _database = initialDatabase; _connectionString = serverConnection.GetConnectionString(credentialService, initialDatabase); _waitStatsSupported = supportsWaitStats; @@ -69,6 +82,11 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi InitializeComponent(); + // The slicer and the ribbon are declared in XAML, so they are handed the holder here, + // before any data reaches them. + TimeRangeSlicer.ServerOffset = serverOffset; + WaitStatsProfile.ServerOffset = serverOffset; + // Apply user defaults to UI controls TopNBox.Value = userSettings.QueryStoreTopLimit; SelectComboByTag(OrderByBox, userSettings.QueryStoreDefaultMetric); @@ -80,6 +98,11 @@ public QueryStoreGridControl(ServerConnection serverConnection, ICredentialServi _ => "query-hash" }); + /* The time display mode is one setting for the whole app, so the box opens on the mode in + effect. It used to open on Local whatever the setting or another grid had chosen, and + could say Local beside times shown in Server mode. The tags are the mode names. */ + SelectComboByTag(TimeDisplayBox, TimeDisplayHelper.Current.ToString()); + // Restore the server-filter panel's expanded state, then subscribe — restoring first // means the restore itself never triggers a save. ServerFilterExpander.IsExpanded = userSettings.QueryStoreFilterPanelExpanded; @@ -156,13 +179,33 @@ private async void QsDatabase_SelectionChanged(object? sender, SelectionChangedE _fetchCts?.Cancel(); + /* Picking a second database before the first one's CheckEnabledAsync lands used to let + whichever finished last win, even for a database the user had already clicked past + (E6). Cancelling the previous check's token here, and this call bailing out below if + it turns out to be the one that got cancelled, makes only the newest pick able to + write _database/_connectionString — same shape as the Overview's load generation + (QuerySessionControl.Views.cs). This has to stay below the early-out above: a revert + below re-fires this handler with the OLD database, which must hit that early-out and + return before it ever reaches here — otherwise the revert's own re-entry would cancel + the very check that superseded it. + + Cancelled, never disposed: the superseded check is still awaiting and reads its own + token when it wakes, and Token on a disposed source throws — same rule as the + Overview's OnSlicerRangeChanged. */ + _databaseCheckCts?.Cancel(); + var cts = new CancellationTokenSource(); + _databaseCheckCts = cts; + // Check if Query Store is enabled on the new database var newConnStr = _serverConnection.GetConnectionString(_credentialService, db); StatusText.Text = "Checking Query Store..."; try { - var (enabled, state, readOnlyReplica) = await QueryStoreService.CheckEnabledAsync(newConnStr); + var (enabled, state, readOnlyReplica) = + await QueryStoreService.CheckEnabledAsync(newConnStr, cts.Token); + if (cts.Token.IsCancellationRequested) return; // superseded — the newer pick owns this + if (!enabled) { StatusText.Text = readOnlyReplica @@ -172,8 +215,14 @@ private async void QsDatabase_SelectionChanged(object? sender, SelectionChangedE return; } } + catch (OperationCanceledException) + { + return; // superseded — the newer pick owns this + } catch (Exception ex) { + if (cts.Token.IsCancellationRequested) return; // superseded + /* Was cut to 60 characters + "..." before display. Trimming to the space available is the display layer's job — the strip clips at its own edge — and pre-cutting here also destroyed the only recovery path: the tooltip the constructor mirrors @@ -334,19 +383,26 @@ public class QueryStoreRow : INotifyPropertyChanged private bool _isExpanded; private int _indentLevel; + // The connection's offset holder (E5): read each time the row formats its time, so Server + // mode shows this row's own server's time whatever else the process is connected to. + private readonly ServerUtcOffset _serverOffset; + /// Standard constructor for flat (ungrouped) rows. - public QueryStoreRow(QueryStorePlan plan) + public QueryStoreRow(QueryStorePlan plan, ServerUtcOffset serverOffset) { Plan = plan; + _serverOffset = serverOffset; } /// Constructor for grouped parent/intermediate rows (aggregated, no single plan). - public QueryStoreRow(QueryStorePlan syntheticPlan, int indentLevel, string groupLabel, List children) + public QueryStoreRow(QueryStorePlan syntheticPlan, int indentLevel, string groupLabel, List children, + ServerUtcOffset serverOffset) { Plan = syntheticPlan; _indentLevel = indentLevel; GroupLabel = groupLabel; Children = children; + _serverOffset = serverOffset; } public QueryStorePlan Plan { get; } @@ -520,7 +576,7 @@ public double WaitMaxGrandTotal public long TotalMemSort => Plan.TotalMemoryGrantPages; public double AvgMemSort => Plan.AvgMemoryGrantPages; - public string LastExecutedLocal => TimeDisplayHelper.FormatForDisplay(Plan.LastExecutedUtc); + public string LastExecutedLocal => TimeDisplayHelper.FormatForDisplay(Plan.LastExecutedUtc, _serverOffset.Minutes); public void NotifyTimeDisplayChanged() => OnPropertyChanged(nameof(LastExecutedLocal)); diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Chart.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Chart.cs index 1c043a10..8fb48879 100644 --- a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Chart.cs +++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Chart.cs @@ -49,7 +49,7 @@ private void UpdateChart() var color = _planHashColorMap.GetValueOrDefault(planHash, PlanColors[0]); var ordered = group.OrderBy(r => r.IntervalStartUtc).ToList(); - var xs = ordered.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray(); + var xs = ordered.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc, _serverOffset.Minutes).ToOADate()).ToArray(); var ys = ordered.Select(r => GetMetricValue(r, tag)).ToArray(); var scatter = HistoryChart.Plot.Add.Scatter(xs, ys); @@ -158,7 +158,7 @@ private void HighlightDotsOnChart(HashSet rowIndices) foreach (var group in groups) { var color = _planHashColorMap.GetValueOrDefault(group.Key, PlanColors[0]); - var xs = group.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc).ToOADate()).ToArray(); + var xs = group.Select(r => TimeDisplayHelper.ConvertForDisplay(r.IntervalStartUtc, _serverOffset.Minutes).ToOADate()).ToArray(); var ys = group.Select(r => GetMetricValue(r, tag)).ToArray(); var highlight = HistoryChart.Plot.Add.Scatter(xs, ys); diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Fetch.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Fetch.cs index b88d110b..7ff4b138 100644 --- a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Fetch.cs +++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Fetch.cs @@ -46,6 +46,12 @@ private async System.Threading.Tasks.Task LoadHistoryAsync() _connectionString, _queryHash, _maxHoursBack, ct); } + /* The rows come back from Core, which knows nothing about connections. Each one is given + this control's holder here, before anything binds them, so its own time columns read + the server it was fetched from (E5). */ + foreach (var row in _historyData) + row.ServerOffset = _serverOffset; + BuildColorMap(); HistoryDataGrid.ItemsSource = _historyData; ApplyColorIndicators(); @@ -54,8 +60,8 @@ private async System.Threading.Tasks.Task LoadHistoryAsync() { var planCount = _historyData.Select(r => r.QueryPlanHash).Distinct().Count(); var totalExec = _historyData.Sum(r => r.CountExecutions); - var first = TimeDisplayHelper.ConvertForDisplay(_historyData.Min(r => r.IntervalStartUtc)); - var last = TimeDisplayHelper.ConvertForDisplay(_historyData.Max(r => r.IntervalStartUtc)); + var first = TimeDisplayHelper.ConvertForDisplay(_historyData.Min(r => r.IntervalStartUtc), _serverOffset.Minutes); + var last = TimeDisplayHelper.ConvertForDisplay(_historyData.Max(r => r.IntervalStartUtc), _serverOffset.Minutes); StatusText.Text = $"{_historyData.Count} intervals, {planCount} plan(s), " + $"{totalExec:N0} total executions | " + $"{first:MM/dd HH:mm} to {last:MM/dd HH:mm}"; diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Selection.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Selection.cs index 7e2bc7f9..cdb1fdda 100644 --- a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Selection.cs +++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.Selection.cs @@ -128,7 +128,7 @@ private void HandleSingleClickSelection(Point clickPoint) for (int i = 0; i < _historyData.Count; i++) { var row = _historyData[i]; - var displayTime = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc); + var displayTime = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc, _serverOffset.Minutes); if (row.QueryPlanHash == bestPlanHash && Math.Abs((displayTime - clickedTime).TotalMinutes) < 1) { @@ -154,7 +154,7 @@ private void HandleBoxSelection(ScottPlot.Coordinates start, ScottPlot.Coordinat for (int i = 0; i < _historyData.Count; i++) { var row = _historyData[i]; - var xVal = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc).ToOADate(); + var xVal = TimeDisplayHelper.ConvertForDisplay(row.IntervalStartUtc, _serverOffset.Minutes).ToOADate(); var yVal = GetMetricValue(row, tag); if (xVal >= x1 && xVal <= x2 && yVal >= y1 && yVal <= y2) diff --git a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs index 155eff91..ed475c14 100644 --- a/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QueryStoreHistoryControl.axaml.cs @@ -22,6 +22,11 @@ public partial class QueryStoreHistoryControl : UserControl private readonly string _queryHash; private readonly string _database; private readonly string _queryText; + /// + /// The offset holder of the connection this History was opened from (E5): the grid's, not the + /// session's current one, because the data it fetches comes from the grid's server. + /// + private readonly ServerUtcOffset _serverOffset; private readonly DateTime? _slicerStartUtc; private readonly DateTime? _slicerEndUtc; private readonly int _maxHoursBack; @@ -117,11 +122,12 @@ public QueryStoreHistoryControl() _queryHash = ""; _database = ""; _queryText = ""; + _serverOffset = new ServerUtcOffset(); InitializeComponent(); } public QueryStoreHistoryControl(string connectionString, string queryHash, - string queryText, string database, + string queryText, string database, ServerUtcOffset serverOffset, string initialMetricTag = "AvgCpuMs", DateTime? slicerStartUtc = null, DateTime? slicerEndUtc = null, int slicerDaysBack = 30) @@ -130,6 +136,7 @@ public QueryStoreHistoryControl(string connectionString, string queryHash, _queryHash = queryHash; _database = database; _queryText = queryText; + _serverOffset = serverOffset; _slicerStartUtc = slicerStartUtc; _slicerEndUtc = slicerEndUtc; _maxHoursBack = slicerDaysBack * 24; diff --git a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.WaitStatsChart.cs b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.WaitStatsChart.cs index 917551d6..fa2ea9a8 100644 --- a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.WaitStatsChart.cs +++ b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.WaitStatsChart.cs @@ -169,7 +169,7 @@ private void DrawWaitStatsChart() }); var tb = new TextBlock { - Text = TimeDisplayHelper.FormatForDisplay(allHours[i], "MM/dd"), + Text = TimeDisplayHelper.FormatForDisplay(allHours[i], _serverOffset.Minutes, "MM/dd"), FontSize = 8, Foreground = labelBrush, }; diff --git a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs index ab2bf54d..52e44a81 100644 --- a/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QueryStoreOverviewControl.axaml.cs @@ -18,12 +18,42 @@ public partial class QueryStoreOverviewControl : UserControl { private readonly ServerConnection _serverConnection; private readonly ICredentialService _credentialService; + /// + /// The offset holder of the connection this Overview was built on (E5). A reconnect throws the + /// Overview away and builds a new one (QuerySessionControl.InvalidateOverviewView), so this is + /// always the holder of the server whose data is on screen. + /// + private readonly ServerUtcOffset _serverOffset; private readonly string _masterConnectionString; private readonly int _maxDop; private readonly int _topN; private readonly bool _supportsWaitStats; private CancellationTokenSource? _cts; + /// + /// The token source of the load or refresh that is running right now, or null when nothing is. + /// Set where and take the token over, + /// and cleared in their finally only if it is still theirs — read by the detach handler + /// so it knows whether cancelling is aborting real work or just tidying up an already-finished + /// token ( stays non-null after a load succeeds, so it cannot answer that on + /// its own). + /// + /// By identity rather than a flag because the two overlap on every ordinary load: loading + /// the slicer raises RangeChanged, whose handler cancels the load's token and runs the long + /// metrics phase on a token of its own, while the cancelled load unwinds and reaches its + /// finally first. A flag cleared there would say nothing is running for the whole of + /// the slowest phase — exactly when a switch away is likeliest. + /// + private CancellationTokenSource? _runningCts; + + /// + /// Set when a detach cancelled a load that was still running — the load the user would have + /// seen finish if they had not switched away. Consumed by the next attach, which restarts it + /// with the same time range; a load that had already finished never sets this, so coming back + /// to a view that is already showing its data does not reload it for nothing (E3). + /// + private bool _reloadOnAttach; + private List _states = new(); private List _metrics = new(); private List _timeSlices = new(); @@ -62,10 +92,12 @@ public class DrillDownEventArgs(string database, DateTime startUtc, DateTime end public event EventHandler? DrillDownRequested; public QueryStoreOverviewControl(ServerConnection serverConnection, - ICredentialService credentialService, int maxDop = 8, int? topN = null, bool supportsWaitStats = true) + ICredentialService credentialService, ServerUtcOffset serverOffset, + int maxDop = 8, int? topN = null, bool supportsWaitStats = true) { _serverConnection = serverConnection; _credentialService = credentialService; + _serverOffset = serverOffset; _masterConnectionString = serverConnection.GetConnectionString(credentialService, "master"); _maxDop = maxDop; @@ -90,6 +122,9 @@ that a colour identifies a database everywhere. A database this palette cannot n InitializeComponent(); + // Declared in XAML, so handed the holder here, before any data reaches it. + OverviewTimeSlicer.ServerOffset = serverOffset; + /* Only the wait stats chart is drawn into a Canvas at absolute coordinates, so it is the only thing left that has to be redrawn when the control resizes. The state card and the metric cards are laid out by the panels they live in and reflow on their own. */ @@ -105,9 +140,43 @@ metric cards are laid out by the panels they live in and reflow on their own. */ and ObjectDisposedException is not what its caller is catching. */ this.DetachedFromVisualTree += (_, _) => { + /* A load left running is one the user would have seen land if they had not switched + away — remembered here so the next attach can pick it back up (E3). The claim is + dropped along with the token: what is cancelled next is about to unwind, and the + question for the next detach is only ever about work started since. */ + _reloadOnAttach = _runningCts != null; + _runningCts = null; _cts?.Cancel(); _cts = null; }; + + /* The session detaches this control's whole host when the user switches to another + top-level tab (QuerySessionControl.axaml.cs) — switching the view's own segment does + not, ApplySurface only toggles IsVisible, so this never fires from that. Only the + detach above sets _reloadOnAttach, so an attach after a load that already finished, + failed, or was cancelled some other way restarts nothing. */ + this.AttachedToVisualTree += async (_, _) => + { + if (!_reloadOnAttach) return; + _reloadOnAttach = false; + + try + { + await LoadAsync(); + } + catch (OperationCanceledException) + { + // Superseded by yet another load before this one finished — that one reports. + } + catch (Exception ex) + { + /* Nothing awaits this handler, so an escaped exception here has nowhere to go + but the process — same reasoning as ShowOverviewCoreAsync's own catch + (QuerySessionControl.Views.cs). The session may not even be looking at this + view by the time it lands, which is exactly what this badge is for. */ + ShowRefreshError(ex); + } + }; } /// @@ -123,8 +192,10 @@ and ObjectDisposedException is not what its caller is catching. */ public async Task LoadAsync() { _cts?.Cancel(); - _cts = new CancellationTokenSource(); - var ct = _cts.Token; + var cts = new CancellationTokenSource(); + _cts = cts; + _runningCts = cts; + var ct = cts.Token; await Dispatcher.UIThread.InvokeAsync(() => { @@ -198,6 +269,11 @@ whose handler takes the token over to run the same last phase. */ } finally { + /* Only if the claim is still this run's. Loading the slicer hands the token to + OnSlicerRangeChanged, so on the ordinary path this run unwinds while the refresh it + started is only beginning its slowest phase — and that refresh's claim is the one + that has to survive. */ + if (ReferenceEquals(_runningCts, cts)) _runningCts = null; await Dispatcher.UIThread.InvokeAsync(() => LoadingBar.IsIndeterminate = false); } } @@ -309,6 +385,7 @@ private async void OnSlicerRangeChanged(object? sender, TimeRangeChangedEventArg _cts?.Cancel(); var newCts = new CancellationTokenSource(); _cts = newCts; + _runningCts = newCts; ClearRefreshError(); try @@ -320,6 +397,10 @@ private async void OnSlicerRangeChanged(object? sender, TimeRangeChangedEventArg { ShowRefreshError(ex); } + finally + { + if (ReferenceEquals(_runningCts, newCts)) _runningCts = null; + } } /// diff --git a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs index bb48cfaf..cc658a45 100644 --- a/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs +++ b/src/PlanViewer.App/Controls/TimeRangeSlicerControl.axaml.cs @@ -58,6 +58,13 @@ private enum DragMode { None, MoveRange, DragStart, DragEnd, SelectRect } public event EventHandler? RangeChanged; + /// + /// The offset holder of the connection whose data this slicer shows (E5). Assigned by the + /// owner right after InitializeComponent, before any data is loaded. A slicer nobody assigns + /// keeps a holder of its own at zero, which reads as UTC in Server mode. + /// + public ServerUtcOffset ServerOffset { get; set; } = new(); + public TimeRangeSlicerControl() { _activeFilterTag = AppSettingsService.Load().QueryStoreDefaultTimeRange; @@ -321,8 +328,8 @@ private void PopulatePickersFromSelection() { if (_data.Count == 0) return; - var startDisplay = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeStart)); - var endDisplay = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeEnd)); + var startDisplay = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeStart), ServerOffset.Minutes); + var endDisplay = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeEnd), ServerOffset.Minutes); StartDatePicker.SelectedDate = startDisplay.Date; StartTimePicker.SelectedTime = startDisplay.TimeOfDay; @@ -331,21 +338,26 @@ private void PopulatePickersFromSelection() EndTimePicker.SelectedTime = endDisplay.TimeOfDay; // Set display date range limits from data bounds - var firstDisplay = TimeDisplayHelper.ConvertForDisplay(_data[0].IntervalStartUtc); - var lastDisplay = TimeDisplayHelper.ConvertForDisplay(_data[^1].IntervalStartUtc.AddHours(1)); + var firstDisplay = TimeDisplayHelper.ConvertForDisplay(_data[0].IntervalStartUtc, ServerOffset.Minutes); + var lastDisplay = TimeDisplayHelper.ConvertForDisplay(_data[^1].IntervalStartUtc.AddHours(1), ServerOffset.Minutes); StartDatePicker.DisplayDateStart = firstDisplay.Date; StartDatePicker.DisplayDateEnd = lastDisplay.Date; EndDatePicker.DisplayDateStart = firstDisplay.Date; EndDatePicker.DisplayDateEnd = lastDisplay.Date; } - private static DateTime ConvertFromDisplay(DateTime displayTime) + /// + /// The inverse of : what the + /// custom-range popup was typed in, back to UTC. Server mode subtracts this slicer's own + /// connection's offset (E5). Internal so a test can pin it without driving two pickers. + /// + internal DateTime ConvertFromDisplay(DateTime displayTime) { return TimeDisplayHelper.Current switch { TimeDisplayMode.Local => displayTime.ToUniversalTime(), TimeDisplayMode.Utc => DateTime.SpecifyKind(displayTime, DateTimeKind.Utc), - TimeDisplayMode.Server => displayTime.AddMinutes(-TimeDisplayHelper.ServerUtcOffsetMinutes), + TimeDisplayMode.Server => displayTime.AddMinutes(-ServerOffset.Minutes), _ => displayTime.ToUniversalTime() }; } @@ -464,7 +476,7 @@ public void Redraw() for (int i = 0; i < n; i += labelInterval) { var x = i * stepX + stepX / 2; - var dt = TimeDisplayHelper.ConvertForDisplay(_data[i].IntervalStartUtc); + var dt = TimeDisplayHelper.ConvertForDisplay(_data[i].IntervalStartUtc, ServerOffset.Minutes); var label = dt.ToString("MM/dd HH:mm"); var tb = new TextBlock { @@ -493,8 +505,8 @@ public void Redraw() var dayLineBrush = TryFindBrush("SlicerLabelBrush", FallbackDayLineBrush); for (int di = 1; di < n; di++) { - var prevDisplay = TimeDisplayHelper.ConvertForDisplay(_data[di - 1].IntervalStartUtc); - var curDisplay = TimeDisplayHelper.ConvertForDisplay(_data[di].IntervalStartUtc); + var prevDisplay = TimeDisplayHelper.ConvertForDisplay(_data[di - 1].IntervalStartUtc, ServerOffset.Minutes); + var curDisplay = TimeDisplayHelper.ConvertForDisplay(_data[di].IntervalStartUtc, ServerOffset.Minutes); if (curDisplay.Date != prevDisplay.Date) { var xDay = di * stepX; // left edge of the bucket where the new day starts @@ -651,7 +663,7 @@ public void Redraw() var dotBrush = TryFindBrush("SlicerChartLineBrush", FallbackChartLineBrush); for (int i = 0; i < n; i++) { - var bucketDisplay = TimeDisplayHelper.ConvertForDisplay(_data[i].IntervalStartUtc); + var bucketDisplay = TimeDisplayHelper.ConvertForDisplay(_data[i].IntervalStartUtc, ServerOffset.Minutes); var bucketDisplayEnd = bucketDisplay.AddHours(1); var val = values[i]; var valText = _metric is "executions" ? $"{val:N0}" : $"{val:N2}"; @@ -1026,8 +1038,8 @@ private void UpdateRangeLabel() RangeLabel.Text = ""; return; } - var start = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeStart)); - var end = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeEnd)); + var start = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeStart), ServerOffset.Minutes); + var end = TimeDisplayHelper.ConvertForDisplay(GetDateTimeAtNorm(_rangeEnd), ServerOffset.Minutes); var span = end - start; var spanText = span.TotalHours >= 48 ? $"{span.TotalDays:F1}d" diff --git a/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml.cs b/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml.cs index 690d3ff1..f0ca31b9 100644 --- a/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml.cs +++ b/src/PlanViewer.App/Controls/WaitStatsProfileControl.axaml.cs @@ -8,6 +8,7 @@ using Avalonia.Layout; using Avalonia.Media; using PlanViewer.Core.Models; +using PlanViewer.Core.Services; namespace PlanViewer.App.Controls; @@ -26,6 +27,16 @@ private enum ViewMode { Bar, Ribbon } public bool IsCollapsed => _isCollapsed; + /// + /// The offset holder of the connection whose data this profile shows, handed on to the ribbon + /// that draws the times (E5). Assigned by the owning grid right after InitializeComponent. + /// + public ServerUtcOffset ServerOffset + { + get => GlobalRibbon.ServerOffset; + set => GlobalRibbon.ServerOffset = value; + } + // All known wait categories in the order they appear in the theme private static readonly string[] AllWaitCategories = [ @@ -56,6 +67,9 @@ public void SetRibbonData(List data) GlobalRibbon.SetData(data); } + /// Redraws the ribbon with the data it has, after the time display mode changes. + internal void RedrawRibbon() => GlobalRibbon.Redraw(); + public void SetHighlight(string? category) { GlobalBar.SetHighlight(category); diff --git a/src/PlanViewer.App/Controls/WaitStatsRibbonControl.axaml.cs b/src/PlanViewer.App/Controls/WaitStatsRibbonControl.axaml.cs index b34d9092..02a93e71 100644 --- a/src/PlanViewer.App/Controls/WaitStatsRibbonControl.axaml.cs +++ b/src/PlanViewer.App/Controls/WaitStatsRibbonControl.axaml.cs @@ -19,6 +19,13 @@ public partial class WaitStatsRibbonControl : UserControl private List _data = new(); private string? _highlightCategory; + /// + /// The offset holder of the connection whose data this ribbon shows (E5). Assigned by the + /// owner right after InitializeComponent. A ribbon nobody assigns keeps a holder of its own at + /// zero, which reads as UTC in Server mode. + /// + public ServerUtcOffset ServerOffset { get; set; } = new(); + public event EventHandler? CategoryClicked; public event EventHandler? CategoryDoubleClicked; @@ -43,7 +50,7 @@ public void SetHighlight(string? category) Redraw(); } - private void Redraw() + internal void Redraw() { RibbonCanvas.Children.Clear(); if (_data.Count == 0) return; @@ -171,10 +178,10 @@ private void Redraw() var intervalStart = hour; var intervalEnd = intervalStart.AddHours(1); - var startDisplay = TimeDisplayHelper.FormatForDisplay(intervalStart, "yyyy-MM-dd HH:mm"); + var startDisplay = TimeDisplayHelper.FormatForDisplay(intervalStart, ServerOffset.Minutes, "yyyy-MM-dd HH:mm"); var endDisplay = intervalStart.Date == intervalEnd.Date - ? TimeDisplayHelper.FormatForDisplay(intervalEnd, "HH:mm") - : TimeDisplayHelper.FormatForDisplay(intervalEnd, "yyyy-MM-dd HH:mm"); + ? TimeDisplayHelper.FormatForDisplay(intervalEnd, ServerOffset.Minutes, "HH:mm") + : TimeDisplayHelper.FormatForDisplay(intervalEnd, ServerOffset.Minutes, "yyyy-MM-dd HH:mm"); var tipBlock = new TextBlock { Text = $"{cat}: {WaitRatioFormatter.Format(ratio)}\n{startDisplay} \u2013 {endDisplay}", @@ -261,7 +268,7 @@ private void Redraw() var dt = allHours[i]; var tb = new TextBlock { - Text = TimeDisplayHelper.FormatForDisplay(dt, "MM/dd"), + Text = TimeDisplayHelper.FormatForDisplay(dt, ServerOffset.Minutes, "MM/dd"), FontSize = 8, Foreground = labelBrush, }; @@ -279,7 +286,7 @@ private void Redraw() var dt = allHours[i]; var tb = new TextBlock { - Text = TimeDisplayHelper.FormatForDisplay(dt, "MM/dd HH:mm"), + Text = TimeDisplayHelper.FormatForDisplay(dt, ServerOffset.Minutes, "MM/dd HH:mm"), FontSize = 8, Foreground = labelBrush, }; diff --git a/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml.cs b/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml.cs index b60dcfdd..d8bedd9c 100644 --- a/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml.cs +++ b/src/PlanViewer.App/Dialogs/ConnectionDialog.axaml.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; using Avalonia.Controls; @@ -317,7 +318,8 @@ reading them again after the await could save a server that was never tested. */ } // Save connection to store - _connectionStore.AddOrUpdate(connection); + if (!TrySaveConnection(connection)) + return; ResultConnection = connection; ResultDatabase = ResolveResultDatabase(typedDatabase); @@ -325,6 +327,28 @@ reading them again after the await could save a server that was never tested. */ Close(true); } + /// + /// Saves through the store, reporting why in StatusText rather + /// than letting the failure escape this async void handler and crash the app. ConnectionStore + /// throws IOException when it refused to overwrite a connections.json it could not read on + /// its last load (see ConnectionStore.Save) — a real failure the user needs to see, not a bug + /// to let past this dialog. + /// + private bool TrySaveConnection(ServerConnection connection) + { + try + { + _connectionStore.AddOrUpdate(connection); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + StatusText.Text = ex.Message; + StatusText.Foreground = StatusBrush("ErrorBrush", Avalonia.Media.Brushes.OrangeRed); + return false; + } + } + /// /// The database the session should open: the Database dropdown when it has a selection, /// otherwise the named initial database that was validated, otherwise master. diff --git a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs index 840d8444..73225306 100644 --- a/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs +++ b/src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs @@ -2,6 +2,7 @@ using Avalonia.Controls; using PlanViewer.App.Controls; using PlanViewer.App.Services; +using PlanViewer.Core.Services; namespace PlanViewer.App.Dialogs; @@ -18,7 +19,7 @@ public QueryStoreHistoryWindow() } public QueryStoreHistoryWindow(string connectionString, string queryHash, - string queryText, string database, + string queryText, string database, ServerUtcOffset serverOffset, string? initialMetricTag = null, DateTime? slicerStartUtc = null, DateTime? slicerEndUtc = null, int? slicerDaysBack = null) @@ -30,7 +31,7 @@ public QueryStoreHistoryWindow(string connectionString, string queryHash, var daysBack = slicerDaysBack ?? settings.QueryStoreSlicerDays; var control = new QueryStoreHistoryControl( - connectionString, queryHash, queryText, database, + connectionString, queryHash, queryText, database, serverOffset, metricTag, slicerStartUtc, slicerEndUtc, daysBack); control.ShowCloseButton(true); Content = control; diff --git a/src/PlanViewer.App/Dialogs/SettingsWindow.axaml b/src/PlanViewer.App/Dialogs/SettingsWindow.axaml index 90e19b2c..c340b804 100644 --- a/src/PlanViewer.App/Dialogs/SettingsWindow.axaml +++ b/src/PlanViewer.App/Dialogs/SettingsWindow.axaml @@ -33,20 +33,28 @@ - - /// /// Whether an empty restore opens a fresh query tab. False when the caller is about to diff --git a/src/PlanViewer.App/MainWindow.PlanViewer.cs b/src/PlanViewer.App/MainWindow.PlanViewer.cs index 34faebec..dd9f5235 100644 --- a/src/PlanViewer.App/MainWindow.PlanViewer.cs +++ b/src/PlanViewer.App/MainWindow.PlanViewer.cs @@ -80,7 +80,9 @@ internal DockPanel CreatePlanTabContent(PlanViewerControl viewer) /* #430: the same unguarded-click-handler crash as QuerySessionControl's Robot Advice button — this entry point builds the payload independently, so it needed the same depth ceiling and the same guard. */ - ShowError($"Could not build robot advice for this plan: {ex.Message}"); + ShowError(ex is JsonException + ? $"Could not build robot advice for this plan. {AnalysisJson.TooDeepMessage}" + : $"Could not build robot advice for this plan: {ex.Message}"); return; } @@ -405,7 +407,7 @@ void UpdateCompareEnabled() try { - var doc = XDocument.Parse(planXml); + var doc = PlanXml.Parse(planXml); XNamespace ns = "http://schemas.microsoft.com/sqlserver/2004/07/showplan"; var stmt = doc.Descendants(ns + "StmtSimple").FirstOrDefault(); var dbContext = stmt?.Attribute("DatabaseContext")?.Value; @@ -518,11 +520,7 @@ emailed around. Name the target so "which server was that?" is answered if (ke.Key == Avalonia.Input.Key.Escape) { cts.Cancel(); ke.Handled = true; } }; - var tab = CreateTab("Actual Plan", loadingContainer); - MainTabControl.Items.Add(tab); - MainTabControl.SelectedItem = tab; - UpdateEmptyOverlay(); - loadingContainer.Focus(); + var tab = AddLoadingTab("Actual Plan", loadingContainer, cts); try { @@ -548,6 +546,11 @@ emailed around. Name the target so "which server was that?" is answered sw.Stop(); + /* Closing the tab cancels this run, but the answer can already be on its way back. + Building a viewer for a tab nobody can see would register a plan that nothing is + left to unregister. */ + cts.Token.ThrowIfCancellationRequested(); + if (string.IsNullOrEmpty(actualPlanXml)) { statusText.Text = $"No actual plan returned ({sw.Elapsed.TotalSeconds:F1}s)."; diff --git a/src/PlanViewer.App/MainWindow.ScratchPersist.cs b/src/PlanViewer.App/MainWindow.ScratchPersist.cs index 3433e82d..10519a86 100644 --- a/src/PlanViewer.App/MainWindow.ScratchPersist.cs +++ b/src/PlanViewer.App/MainWindow.ScratchPersist.cs @@ -99,6 +99,16 @@ different questions (staleness against on-disk changes, most of all). That is #4 /// private void HookScratchPersistence(QuerySessionControl session) { + /* A secondary instance never persists scratch content. The buffer folder and the ids + in it belong to the instance that owns the slot: writing there would put this + window's text under names the owner also writes, drops and sweeps. With no + subscription nothing is ever queued, no buffer id is minted, and every place that + drops a buffer finds no id and does nothing — the drop sites need no guard of + their own. The cost is crash recovery for this window's scratch tabs; the close + prompts do not depend on it. */ + if (SingleInstance.IsSecondaryInstance) + return; + /* Only sessions that are scratch NOW. SourceFilePath moves null→path exactly once (the save) and never back, so a session arriving here file-backed can never need this hook later — the scope fence again, applied at subscription time. It also diff --git a/src/PlanViewer.App/MainWindow.Tabs.cs b/src/PlanViewer.App/MainWindow.Tabs.cs index cd75a37c..c60b8943 100644 --- a/src/PlanViewer.App/MainWindow.Tabs.cs +++ b/src/PlanViewer.App/MainWindow.Tabs.cs @@ -174,6 +174,73 @@ private void CloseTab_Click(object? sender, RoutedEventArgs e) _ = TryCloseTabAsync(tab); } + /// + /// Which run each window-level loading panel is showing, so closing the tab that holds the + /// panel can stop the run. Keyed by the panel rather than the tab because the panel is what + /// moves if the tab is detached. Weak, so a panel that was replaced by its plan takes its + /// entry with it. + /// + private readonly ConditionalWeakTable _loadingRuns = new(); + + /// + /// Opens a tab that shows a progress panel for a run that will fill it in later, and selects it. + /// The run is remembered against the panel, so closing the tab cancels it: a Get Actual Plan + /// against a server runs with no timeout, and used to keep running there for a tab nobody + /// could see. + /// + /// + /// Cancelled, never disposed: the panel's Cancel button and Escape handler hold the same + /// source and can fire after anything else has. + /// + internal TabItem AddLoadingTab(string label, Control loadingPanel, CancellationTokenSource run) + { + var tab = CreateTab(label, loadingPanel); + _loadingRuns.AddOrUpdate(loadingPanel, run); + + MainTabControl.Items.Add(tab); + MainTabControl.SelectedItem = tab; + UpdateEmptyOverlay(); + loadingPanel.Focus(); + + return tab; + } + + /// + /// Gives up what a tab's content was holding, once the content is gone for good: the tab has + /// been closed, or the detached window that held it has closed. + /// + /// + /// Every plan viewer registers its plan with when it + /// loads, and only takes it back out. Closing a tab used + /// to remove the tab and nothing else, so the plan stayed in memory and in the MCP + /// list_plans answer until the app exited. It reads the content as it is at close time, + /// so a "Get Actual Plan" tab that started as a spinner is released as the plan viewer that + /// replaced it. + /// + /// Not called on detach or re-dock. There the content moves to another home and is + /// still on screen, so its plan must stay registered. + /// + private void ReleaseTabContent(Control? content) + { + // A tab still waiting on the run that will fill it: stop the run. + if (content != null && _loadingRuns.TryGetValue(content, out var run)) + run.Cancel(); + + switch (content) + { + case QuerySessionControl session: + session.ReleaseOnClose(); + break; + + // Plans opened from a file, a paste or a capture: the viewer is a child of the DockPanel + // that carries the advice toolbar. + case DockPanel dock: + foreach (var viewer in dock.Children.OfType()) + viewer.Clear(); + break; + } + } + private void TabContextMenu_Click(object? sender, RoutedEventArgs e) { if (sender is not MenuItem item) return; @@ -422,6 +489,13 @@ delete the just-written buffer and leave the entry dangling. By then the if (!IsShuttingDown && c is QuerySessionControl { SourceFilePath: null } scratchSession) DropScratchBuffer(scratchSession); + /* The detached twin of TryCloseTabAsync's release: this window closing is the + content leaving the app, so its plans have to come off the MCP session list + too. Not during shutdown, when the process is about to take everything with it + and the windows are being force-closed around a main window that is going away. */ + if (!IsShuttingDown) + ReleaseTabContent(c); + if (c is QueryStoreHistoryControl hc) hc.CancelFetch(); }, diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index 8b5ba158..ab7494d8 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.IO.Pipes; using System.Linq; using System.Text.Json; using System.Threading; @@ -71,7 +70,12 @@ public MainWindow() // Not in the test host (#451): every test window would grab the machine's single // SQLPerformanceStudio_OpenFile pipe slot and never release it — OnClosed never // runs there — racing any real Studio instance on the same box. - if (!AppRuntimeMode.IsTestHost) + // Not in a secondary instance either: the pipe belongs to the instance that owns the + // slot. The listener retries until it gets the pipe, so a secondary would take it once + // the owner exits, and later launches would hand their files to a window whose tabs are + // never saved. Without it, such a launch finds no pipe, claims the slot, and runs as the + // new owner. + if (!AppRuntimeMode.IsTestHost && !SingleInstance.IsSecondaryInstance) StartPipeServer(); InitializeComponent(); @@ -225,7 +229,23 @@ this only changes the cold start. The restore's new-tab fallback stays out of th way when a file is about to open — a stray empty scratch tab beside the file the user asked for is nobody's intent. */ var hasFileArg = args.Length > 1 && File.Exists(args[1]); - RestoreOpenPlans(createFallbackTab: !hasFileArg); + + if (SingleInstance.IsSecondaryInstance) + { + /* A secondary instance (--new-instance beside a running one) does not restore. + The saved list belongs to the instance that owns the slot, which rewrites it on + every tab change: restoring it here would open a copy of every one of its tabs + and share its scratch buffer ids. RestoreOpenPlans is skipped whole, not called + with a flag, because it also clears and saves the list and sweeps the buffer + folder — both of which would land on the owner's files. What is left is what a + launch with nothing to restore does: the file it was given, else a new tab. */ + if (!hasFileArg) + NewQuery_Click(this, new RoutedEventArgs()); + } + else + { + RestoreOpenPlans(createFallbackTab: !hasFileArg); + } if (hasFileArg) OpenFileByExtension(args[1]); @@ -242,9 +262,7 @@ private void StartPipeServer() { try { - using var server = new NamedPipeServerStream( - SingleInstance.PipeName, PipeDirection.In, 1, - PipeTransmissionMode.Byte, PipeOptions.Asynchronous); + using var server = SingleInstance.CreatePipeServer(); await server.WaitForConnectionAsync(token); @@ -337,10 +355,77 @@ private void StartMcpServer() _mcpHost = new McpHostService( PlanSessionManager.Instance, _connectionStore, _credentialService, settings.Port); + // Starting is the honest word for what is true the instant StartAsync is fired off: + // BackgroundService.StartAsync only kicks ExecuteAsync's Task going, it does not wait + // for Kestrel to actually bind — Running or Failed is ReportMcpStartResultAsync's call + // once McpHostService.Started says which one actually happened. + McpStatusMenuItem.Header = BuildMcpStatusHeader(McpServerStatus.Starting, settings.Port); + _ = _mcpHost.StartAsync(_mcpCts.Token); - McpStatusMenuItem.Header = $"MCP Server: Running (port {settings.Port})"; + _ = ReportMcpStartResultAsync(_mcpHost, settings.Port); } + /// + /// Waits for the background host to report how its start actually went, then resolves the + /// menu item to Running or Failed. A cancelled Started (the window closed before Kestrel + /// finished binding) is neither — there is nothing left to show it on, so this returns + /// without touching the menu. + /// + private async Task ReportMcpStartResultAsync(McpHostService host, int port) + { + string? failureReason; + try + { + failureReason = await host.Started; + } + catch (OperationCanceledException) + { + return; + } + + Dispatcher.UIThread.Post(() => + { + // The window may have closed while Started was still pending. + if (IsShuttingDown) + return; + + McpStatusMenuItem.Header = BuildMcpStatusHeader( + failureReason == null ? McpServerStatus.Running : McpServerStatus.Failed, + port, + failureReason); + }); + } + + /// + /// The three states the MCP status menu item shows over a server's lifetime. + /// + internal enum McpServerStatus + { + /// StartAsync has been fired off; Kestrel has not yet said whether it bound. + Starting, + + /// Kestrel is listening on the configured port. + Running, + + /// Kestrel never came up — see the accompanying reason. + Failed + } + + /// + /// The menu text for a status, split out from the update itself so the three outcomes can + /// be tested without a real port or a window to read the menu off of. + /// + internal static string BuildMcpStatusHeader(McpServerStatus status, int port, string? failureReason = null) => + status switch + { + McpServerStatus.Starting => $"MCP Server: Starting (port {port})", + McpServerStatus.Running => $"MCP Server: Running (port {port})", + // A menu header reads "_" as an access key marker, so a reason taken from an + // exception message doubles it to show it as written. + McpServerStatus.Failed => $"MCP Server: Failed ({failureReason?.Replace("_", "__")})", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null) + }; + protected override async void OnClosed(EventArgs e) { try @@ -724,6 +809,11 @@ private async Task TryCloseTabAsync(TabItem tab) MainTabControl.Items.Remove(tab); + /* Every way of closing a tab ends here — the ✕, middle-click, Ctrl+W and the context + menu's Close, Close Other Tabs and Close All Tabs — so this is the one place that has + to let go of what the tab's content was holding. */ + ReleaseTabContent(tab.Content as Control); + /* #496: a scratch tab that actually left the strip has had its fate decided — answered at the prompt above (where Don't Save already dropped and a save made it file-backed, so this is a no-op), or clean and therefore never asked. The clean diff --git a/src/PlanViewer.App/Mcp/McpHelpers.cs b/src/PlanViewer.App/Mcp/McpHelpers.cs index fa871c57..0d82ea39 100644 --- a/src/PlanViewer.App/Mcp/McpHelpers.cs +++ b/src/PlanViewer.App/Mcp/McpHelpers.cs @@ -32,6 +32,8 @@ internal static class McpHelpers return null; } + /* #589: a JsonException here is the operator tree passing AnalysisJson.MaxDepth; the + serializer's own message blames "a possible object cycle". */ public static string FormatError(string operation, Exception ex) => - $"Error during {operation}: {ex.Message}"; + $"Error during {operation}: {(ex is JsonException ? AnalysisJson.TooDeepMessage : ex.Message)}"; } diff --git a/src/PlanViewer.App/Mcp/McpHostService.cs b/src/PlanViewer.App/Mcp/McpHostService.cs index f457d138..97e3f5e9 100644 --- a/src/PlanViewer.App/Mcp/McpHostService.cs +++ b/src/PlanViewer.App/Mcp/McpHostService.cs @@ -1,7 +1,10 @@ using System; +using System.Net; +using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Connections; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -27,6 +30,15 @@ public sealed class McpHostService : BackgroundService private readonly int _port; private WebApplication? _app; + /// + /// Resolves once knows whether Kestrel came up: null for success, + /// otherwise a short reason a menu item can show. RunContinuationsAsynchronously so a slow + /// UI-thread continuation (see MainWindow.ReportMcpStartResultAsync) never runs inline on + /// this service's own async state machine. + /// + private readonly TaskCompletionSource _startResult = + new(TaskCreationOptions.RunContinuationsAsynchronously); + public McpHostService( PlanSessionManager sessionManager, ConnectionStore connectionStore, @@ -39,11 +51,25 @@ public McpHostService( _port = port; } + /// + /// How the start went: null once Kestrel is actually listening, a short reason if it never + /// came up. Cancelled instead of completed if the host stopped before either happened — the + /// window closing mid-start is neither outcome, and MainWindow treats that the same as "no + /// longer anyone to tell". + /// + internal Task Started => _startResult.Task; + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { try { - var builder = WebApplication.CreateBuilder(); + /* An empty builder, not CreateBuilder. CreateBuilder also reads appsettings files + from the working directory and the process's environment variables, and a + Kestrel section in either one adds endpoints beside the loopback one set below. + This server takes its whole setup from this code. */ + var builder = WebApplication.CreateEmptyBuilder(new WebApplicationOptions()); + builder.WebHost.UseKestrelCore(); + builder.Services.AddRoutingCore(); builder.WebHost.ConfigureKestrel(options => { @@ -93,6 +119,14 @@ web page can point a DNS name it controls at 127.0.0.1 and reach this loopback origin, before it can touch an MCP endpoint. */ _app.Use(async (context, next) => { + /* Only this machine may connect. Kestrel is bound to loopback, so this + check is the second of two; it keeps holding if the binding ever changes. */ + if (!IsLoopbackAddress(context.Connection.RemoteIpAddress)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + if (!IsLoopbackHost(context.Request.Host.Host)) { context.Response.StatusCode = StatusCodes.Status403Forbidden; @@ -111,18 +145,78 @@ web page can point a DNS name it controls at 127.0.0.1 and reach this _app.MapMcp(); - await _app.RunAsync(stoppingToken); + /* Split from the single RunAsync the rest of this class used to call: StartAsync + is the half that can fail to bind (another process already on _port, most often), + and it has to be awaited on its own so that failure reaches _startResult instead + of vanishing into RunAsync's combined start-then-wait Task. WaitForShutdownAsync + is the old wait-forever half, unchanged. */ + await _app.StartAsync(stoppingToken); + _startResult.TrySetResult(null); + + await _app.WaitForShutdownAsync(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { - /* Normal shutdown */ + /* Normal shutdown. If this fired before StartAsync returned, the finally block + below resolves Started as cancelled rather than failed — the window closing + mid-bind isn't a start failure, it's just nobody left to tell either way. */ } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"MCP server failed to start: {ex.Message}"); + _startResult.TrySetResult(DescribeStartFailure(ex, _port)); + } + finally + { + /* A safety net, not the normal path: TrySetResult above already settled Started for + the two outcomes MainWindow shows. This only fires if neither did — a start still + in flight when the token was cancelled — and TrySetCanceled is a no-op once a + result is already set, so it never overwrites a real success or failure. */ + _startResult.TrySetCanceled(); } } + /// + /// A short reason for the MCP status menu item. Kestrel wraps a taken port as an IOException + /// whose InnerException is AddressInUseException, but the socket layer beneath it can also + /// surface a bare SocketException(AddressAlreadyInUse) — seen on some platform/transport + /// combinations without the Kestrel wrapper — so the whole chain is walked rather than just + /// the outermost exception or its immediate InnerException. Anything else reports the port + /// and the first line of the exception's own message, cut to a length a menu item can show. + /// + internal static string DescribeStartFailure(Exception ex, int port) + { + for (var current = ex; current != null; current = current.InnerException) + { + if (current is AddressInUseException + || current is SocketException { SocketErrorCode: SocketError.AddressAlreadyInUse }) + { + return $"port {port} is in use"; + } + } + + var message = ex.Message.AsSpan().Trim(); + var lineEnd = message.IndexOfAny('\r', '\n'); + if (lineEnd >= 0) + message = message[..lineEnd].TrimEnd(); + if (message.Length > MaxReasonLength) + message = string.Concat(message[..MaxReasonLength].TrimEnd(), "..."); + + return $"port {port}: {message}"; + } + + /// The longest failure message the status menu item shows before it is cut. + private const int MaxReasonLength = 100; + + internal static bool IsLoopbackAddress(IPAddress? address) + { + /* No address (a transport other than TCP) is refused, not trusted. */ + if (address == null) + return false; + + return IPAddress.IsLoopback(address.IsIPv4MappedToIPv6 ? address.MapToIPv4() : address); + } + private static bool IsLoopbackHost(string host) { /* HostString.Host keeps IPv6 brackets ([::1]); Uri.Host strips them. */ diff --git a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs index 441aa1b6..05deb385 100644 --- a/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs +++ b/src/PlanViewer.App/Mcp/McpQueryStoreTools.cs @@ -215,6 +215,11 @@ public static async Task GetQueryStoreTop( AnalyzerConfig.Default, serverMetadata, cancellationToken); + /* #589: a plan the parser refuses, such as one past the depth limit, comes + back with ParseError set and no statements. Report it in load_error, not + as a loaded plan with no warnings. */ + if (!string.IsNullOrWhiteSpace(parsed.ParseError)) + throw new InvalidOperationException($"Could not parse plan XML: {parsed.ParseError}"); var session = CaptureSession( sessionId, label, diff --git a/src/PlanViewer.App/PlanViewer.App.csproj b/src/PlanViewer.App/PlanViewer.App.csproj index 497919ca..18bf7fd5 100644 --- a/src/PlanViewer.App/PlanViewer.App.csproj +++ b/src/PlanViewer.App/PlanViewer.App.csproj @@ -9,21 +9,21 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - + diff --git a/src/PlanViewer.App/Program.cs b/src/PlanViewer.App/Program.cs index 82bb239d..f932e35c 100644 --- a/src/PlanViewer.App/Program.cs +++ b/src/PlanViewer.App/Program.cs @@ -1,7 +1,6 @@ using Avalonia; using System; using System.IO; -using System.IO.Pipes; using System.Threading; using System.Threading.Tasks; using PlanViewer.App.Services; @@ -51,7 +50,10 @@ other setting (AtomicFile prevents torn writes, not lost updates). File-argument launches already forwarded to the running instance over the pipe; a BARE second launch ran a full instance and was exactly the clobber case. So unless the user explicitly asks for a second instance, a launch that finds one running hands it - its work — a file path, or a bare "surface yourself" — and exits. */ + its work — a file path, or a bare "surface yourself" — and exits. A launch that + does ask (--new-instance) still gets its own window, but it claims the slot first + and, if another instance already holds it, runs as a secondary that leaves the + saved session alone — see ClaimSlotForNewInstance. */ var newInstanceRequested = SingleInstance.NewInstanceRequested(args); // The flag is a launcher directive, not a file: strip it so nothing downstream can @@ -102,6 +104,11 @@ when the loser's retries run out before the winner's pipe exists. The pre-#489 last-write-wins behavior, which is the accepted floor. */ } } + else + { + // --new-instance: never handed to a running instance, but it still claims the slot. + ClaimSlotForNewInstance(); + } BuildAvaloniaApp() .StartWithClassicDesktopLifetime(effectiveArgs); @@ -119,16 +126,40 @@ public static AppBuilder BuildAvaloniaApp() .WithInterFont() .LogToTrace(); + /// + /// What --new-instance does about the slot. It never hands its launch to the + /// running instance — the user asked for a window of their own — but it no longer skips + /// the slot either. It claims it the same way an ordinary launch does: + /// + /// Slot free: no other instance is running, so this launch is an ordinary one. It + /// restores, persists and owns the slot, and later launches hand their work to it. + /// Slot held: another instance owns the saved session, so this process runs as a + /// secondary () and leaves that session + /// alone. Before this, it restored the owner's list into a second window, and the two + /// windows then wrote, dropped and swept the same scratch buffers. + /// + /// Split from so the decision can be tested with a mutex name of the + /// test's own; the harness cannot start a second app process to hold the real one. + /// + /// Where named mutexes do not work at all, + /// answers true so that the launch still runs, and this launch then acts as an owner: it + /// restores the saved session even if another instance is running. That is how every + /// --new-instance launch behaved before, and it only happens where the mutex + /// machinery itself fails. + /// + internal static void ClaimSlotForNewInstance(string mutexName = SingleInstance.MutexName) => + SingleInstance.IsSecondaryInstance = !TryBecomeSingleInstanceOwner(mutexName); + /// /// Tries to claim the single-instance slot (#489). True means this process is the /// owner and should run; false means another instance holds the slot and this launch - /// should hand its work over instead. + /// should hand its work over instead (or, for --new-instance, run as a secondary). /// - private static bool TryBecomeSingleInstanceOwner() + private static bool TryBecomeSingleInstanceOwner(string mutexName = SingleInstance.MutexName) { try { - var mutex = new Mutex(initiallyOwned: true, SingleInstance.MutexName, out var createdNew); + var mutex = new Mutex(initiallyOwned: true, mutexName, out var createdNew); if (createdNew) { _singleInstanceMutex = mutex; @@ -182,7 +213,7 @@ private static bool TrySendToRunningInstance(string message, int maxAttempts) try { - using var client = new NamedPipeClientStream(".", SingleInstance.PipeName, PipeDirection.Out); + using var client = SingleInstance.CreatePipeClient(); // 500ms per attempt: a running instance's listener is idle and connects // immediately, while Connect burns the full timeout when nothing is // listening — so the single-attempt probe on a with-file launch adds at @@ -196,8 +227,9 @@ private static bool TrySendToRunningInstance(string message, int maxAttempts) } catch { - // Not listening yet, or the single server slot was mid-conversation with - // another client — retry if the budget allows, otherwise report undelivered. + // Not listening yet, the single server slot was mid-conversation with + // another client, or the pipe belongs to another user — retry if the budget + // allows, otherwise report undelivered. } } diff --git a/src/PlanViewer.App/Services/AppSettingsService.cs b/src/PlanViewer.App/Services/AppSettingsService.cs index c3180b5d..d8f8b40c 100644 --- a/src/PlanViewer.App/Services/AppSettingsService.cs +++ b/src/PlanViewer.App/Services/AppSettingsService.cs @@ -24,6 +24,22 @@ internal sealed class AppSettingsService private static AppSettings? _cached; + /// + /// Set by when exists but could not even be + /// read (locked, permissions) — as opposed to missing, or present but unparseable, both of + /// which are fine to overwrite. consults this so a transient read failure + /// can never cost the user their file: it skips quietly, the same way it already does for a + /// write failure. + /// + /// It stays set for the rest of the process, even after a later + /// reads the file. That failed Load handed out defaults, and callers keep what they are + /// given: MainWindow holds its copy for the whole session and passes it to the Settings + /// window, which saves a clone of it. Once the block lifted, either save would write those + /// defaults over the user's file. So the session runs without saving, and the next start + /// reads the file again. + /// + private static bool _saveBlocked; + static AppSettingsService() { SettingsDir = Path.Combine( @@ -69,8 +85,10 @@ internal static void RedirectStorageForTestHost(string directory) ScratchDir = Path.Combine(directory, "scratch"); // Anything cached was loaded from the old location; drop it so the first Load - // after the redirect reads the new one. + // after the redirect reads the new one. A block belongs to the old path too — the + // new one hasn't been read yet, let alone failed to read. _cached = null; + _saveBlocked = false; } private static readonly JsonSerializerOptions JsonOptions = new() @@ -89,13 +107,18 @@ internal static void RedirectStorageForTestHost(string directory) }; /// - /// Loads settings from disk. Returns default settings if the file is missing or corrupt. - /// Migrates legacy format settings from the old standalone file if present. + /// Loads settings from disk. Returns default settings if the file is missing, corrupt, or + /// unreadable. Migrates legacy format settings from the old standalone file if present. /// /// - /// Returns the in-process cached instance — callers must not mutate it. Use - /// if you need an editable copy, or - /// to persist new state (which also refreshes the cache). + /// Returns the in-process cached instance — callers must not mutate it without immediately + /// ing it back (mutate-then-Save is how the rest of this class persists + /// state; see instead when the edit must stay a draft, e.g. + /// the Settings dialog's Cancel button). A file that could not be read (as opposed to missing + /// or unparseable — see ) is never cached: every call keeps + /// retrying the disk, so a caller that loads after a transient lock clears still gets the + /// user's settings. stays refused for the rest of the process, though; + /// see . /// public static AppSettings Load() { @@ -104,15 +127,20 @@ public static AppSettings Load() try { - AppSettings settings; - if (!File.Exists(SettingsPath)) - settings = new AppSettings(); - else + var outcome = SettingsFileStore.Read( + SettingsPath, + nameof(AppSettingsService), + json => JsonSerializer.Deserialize(json, JsonOptions), + out var parsed); + + if (outcome == SettingsFileStore.ReadOutcome.Unreadable) { - var json = File.ReadAllText(SettingsPath); - settings = JsonSerializer.Deserialize(json, JsonOptions) ?? new AppSettings(); + _saveBlocked = true; + return new AppSettings(); } + var settings = parsed ?? new AppSettings(); + // Settings written before the open-tab list held queries use the old key. // Ahead of MigrateFormatSettings, which can Save mid-load and would otherwise // write the old key straight back out. @@ -149,12 +177,45 @@ public static AppSettings Load() public static void Invalidate() => _cached = null; /// - /// Saves settings to disk. Silently ignores write failures. + /// True once a has found the settings file unreadable, which blocks every + /// save for the rest of the process (see ). The Settings window + /// checks it after saving, so it can say the save did not happen instead of closing as if + /// it had. + /// + internal static bool SaveBlocked => _saveBlocked; + + /// What the Settings window shows when is set. + internal static string SaveBlockedMessage => + $"Settings were not saved. {SettingsPath} could not be read earlier in this session, and saving now could replace it with default settings. Restart Performance Studio, then save again."; + + /// + /// Saves settings to disk. Silently ignores write failures — including a save attempted + /// while found the file unreadable, which would otherwise overwrite + /// content this process has never actually seen. The Settings window reports that case + /// through . + /// + /// In a secondary instance () the + /// saved open-tab list is kept as the disk has it. The whole file is written, so the + /// settings object's list goes with it — and a secondary's copy is the one it read at its + /// own startup, long out of date next to the list the owner rewrites on every tab change. + /// Recent plans, the Settings dialog and the server-filter toggle all save through here. + /// The list is read off the disk just before the write, so the write hands back what the + /// owner last saved. An owner save that lands between that read and the write is still + /// lost; nothing locks the file across processes, and the gap is milliseconds. Every other + /// setting stays last-write-wins, which is what two instances on purpose has always meant + /// (see Program.Main). If the file cannot be read there is no list to keep, so the save is + /// skipped rather than written with a guess. /// public static void Save(AppSettings settings) { + if (_saveBlocked) + return; + try { + if (SingleInstance.IsSecondaryInstance && !TryAdoptSavedOpenTabs(settings)) + return; + Directory.CreateDirectory(SettingsDir); var json = JsonSerializer.Serialize(settings, JsonOptions); AtomicFile.WriteAllText(SettingsPath, json); @@ -166,6 +227,32 @@ public static void Save(AppSettings settings) } } + /// + /// Puts the open-tab list that is on disk right now into , for a + /// secondary instance about to write the whole file (see ). Read the way + /// reads, so a file that will not parse is moved aside rather than + /// overwritten. False when the file exists but cannot be read — the caller must not write. + /// + private static bool TryAdoptSavedOpenTabs(AppSettings settings) + { + var outcome = SettingsFileStore.Read( + SettingsPath, + nameof(AppSettingsService), + json => JsonSerializer.Deserialize(json, JsonOptions), + out var onDisk); + + if (outcome == SettingsFileStore.ReadOutcome.Unreadable) + return false; + + if (onDisk != null) + MigrateOpenTabs(onDisk); + + // Missing, or unparseable and just moved aside: there is no list on disk to keep, and + // an empty one is a truer thing to write than a stale one. + settings.OpenTabs = onDisk?.OpenTabs ?? new List(); + return true; + } + /// /// Moves an "open_plans" list written by an older version onto , /// so upgrading does not cost the user the tabs they had open. Only fills an empty OpenTabs: diff --git a/src/PlanViewer.App/Services/AtomicFile.cs b/src/PlanViewer.App/Services/AtomicFile.cs index 581b0bda..186357b0 100644 --- a/src/PlanViewer.App/Services/AtomicFile.cs +++ b/src/PlanViewer.App/Services/AtomicFile.cs @@ -10,6 +10,15 @@ namespace PlanViewer.App.Services; /// internal static class AtomicFile { + /// + /// Matches File.WriteAllText's own default encoding: UTF-8 with an empty preamble (unlike the + /// singleton), so passing no encoding below writes no BOM, exactly + /// as it always has. It also throws on text with no UTF-8 form, such as a lone surrogate, + /// instead of quietly writing U+FFFD — the same refusal File.WriteAllText gives. + /// + private static readonly UTF8Encoding DefaultEncoding = + new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + /// /// Writes to atomically /// with respect to process crashes. If the process dies before the rename, @@ -24,11 +33,30 @@ internal static class AtomicFile public static void WriteAllText(string path, string contents, Encoding? encoding = null) { var tmp = path + ".tmp"; + var actualEncoding = encoding ?? DefaultEncoding; + + // Written by hand rather than through File.WriteAllText/StreamWriter so the flush + // below is reachable: crash-safety needs the .tmp's bytes durable on disk BEFORE the + // rename, not just handed to the OS's write-behind cache, and neither of those helpers + // exposes a way to ask for that. The preamble+bytes split is exactly what + // File.WriteAllText(path, contents, encoding) does internally, so the bytes on disk + // are identical either way — a null encoding writes none (DefaultEncoding's preamble + // is empty), a given one writes its own, e.g. Encoding.Unicode's 2-byte UTF-16 LE mark. + using (var stream = new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None)) + { + var preamble = actualEncoding.GetPreamble(); + if (preamble.Length > 0) + stream.Write(preamble, 0, preamble.Length); + + var textBytes = actualEncoding.GetBytes(contents); + stream.Write(textBytes, 0, textBytes.Length); - if (encoding != null) - File.WriteAllText(tmp, contents, encoding); - else - File.WriteAllText(tmp, contents); + // flushToDisk: true reaches past the OS write-behind cache (FlushFileBuffers on + // Windows, fsync on Unix) — without it, a power loss right after a "successful" + // rename could still leave the renamed file empty, because the rename can land on + // disk before the data it points at does. + stream.Flush(flushToDisk: true); + } // File.Move with overwrite:true maps to MoveFileEx(MOVEFILE_REPLACE_EXISTING) // on Windows and rename(2) on Unix — both atomic when source and destination diff --git a/src/PlanViewer.App/Services/ConnectionStore.cs b/src/PlanViewer.App/Services/ConnectionStore.cs index 6e15e3a9..3963b277 100644 --- a/src/PlanViewer.App/Services/ConnectionStore.cs +++ b/src/PlanViewer.App/Services/ConnectionStore.cs @@ -9,34 +9,71 @@ namespace PlanViewer.App.Services; public class ConnectionStore { - private static readonly string ConfigDir = Path.Combine( + // Not readonly only because RedirectForTestHost exists; nothing in the product assigns + // these outside the static field initializers below (mirrors AppSettingsService). + private static string ConfigDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".planview"); - private static readonly string ConfigFile = Path.Combine(ConfigDir, "connections.json"); + private static string ConfigFile = Path.Combine(ConfigDir, "connections.json"); private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; - public List Load() + /// + /// Set by when exists but could not even be read + /// (locked, permissions), and cleared by the next Load that reads it. + /// refuses to write while this is set — see for why. + /// + private static bool _saveBlocked; + + /// + /// Points this store at a throwaway directory for the test host, the same way + /// does for appsettings.json. + /// Without it, a test that exercised a save path would write the developer's own saved + /// server list — this store's path used to be a plain static readonly field under the real + /// per-user profile, with no such seam. + /// + internal static void RedirectForTestHost(string directory) { - if (!File.Exists(ConfigFile)) - return new List(); + ConfigDir = directory; + ConfigFile = Path.Combine(directory, "connections.json"); + _saveBlocked = false; + } - try - { - var json = File.ReadAllText(ConfigFile); - return JsonSerializer.Deserialize>(json) ?? new List(); - } - catch - { - return new List(); - } + /// + /// The file connections are read from and written to right now. Exposed so the test suite + /// can read and write it directly (mirrors ). + /// + internal static string ConfigFilePath => ConfigFile; + + public List Load() => Load(out _); + + private List Load(out bool unreadable) + { + var outcome = SettingsFileStore.Read>( + ConfigFile, + nameof(ConnectionStore), + json => JsonSerializer.Deserialize>(json), + out var parsed); + + unreadable = outcome == SettingsFileStore.ReadOutcome.Unreadable; + _saveBlocked = unreadable; + + return parsed ?? new List(); } + /// + /// Saves the connection list. Throws , rather than overwriting the + /// file, if the last found unreadable — callers + /// (ConnectionDialog) must catch it and tell the user, not let it crash the app. + /// public void Save(List connections) { + if (_saveBlocked) + throw SettingsFileStore.UnreadableSaveRefused(ConfigFile); + Directory.CreateDirectory(ConfigDir); var json = JsonSerializer.Serialize(connections, JsonOptions); AtomicFile.WriteAllText(ConfigFile, json); @@ -44,7 +81,12 @@ public void Save(List connections) public void AddOrUpdate(ServerConnection connection) { - var connections = Load(); + // This call's own read decides, not _saveBlocked alone: a Load on another thread (the + // MCP server's) can clear that flag between this read and the Save below. + var connections = Load(out var unreadable); + if (unreadable) + throw SettingsFileStore.UnreadableSaveRefused(ConfigFile); + var existing = connections.FirstOrDefault(c => c.ServerName.Equals(connection.ServerName, StringComparison.OrdinalIgnoreCase)); diff --git a/src/PlanViewer.App/Services/SettingsFile.cs b/src/PlanViewer.App/Services/SettingsFile.cs index 184b7c52..fe0cb3ed 100644 --- a/src/PlanViewer.App/Services/SettingsFile.cs +++ b/src/PlanViewer.App/Services/SettingsFile.cs @@ -23,28 +23,44 @@ private static string DefaultPath() => System.IO.Path.Combine( /// redirect necessary in the first place. When this is never called the path keeps its /// default, so the real app is untouched. /// - internal static void RedirectForTestHost(string directory) => + internal static void RedirectForTestHost(string directory) + { Path = System.IO.Path.Combine(directory, "settings.json"); + } - public static JsonObject Read() + public static JsonObject Read() => Read(out _); + + /// True when exists but could not even be read + /// (locked, permissions) — see for why that blocks a write. + private static JsonObject Read(out bool unreadable) { - if (!File.Exists(Path)) - return new JsonObject(); - - try - { - var json = File.ReadAllText(Path); - return JsonNode.Parse(json) as JsonObject ?? new JsonObject(); - } - catch - { - return new JsonObject(); - } + var outcome = SettingsFileStore.Read( + Path, + nameof(SettingsFile), + // A JsonArray (or any other non-object JSON value) casts to null here rather than + // throwing, which is exactly the "valid JSON, wrong shape" case the shared helper + // treats the same as a parse failure — see SettingsFileStore's doc comment. + json => JsonNode.Parse(json) as JsonObject, + out var parsed); + + unreadable = outcome == SettingsFileStore.ReadOutcome.Unreadable; + + return parsed ?? new JsonObject(); } + /// + /// Reads, mutates, and writes back. Throws , rather than + /// overwriting the file, if the read here finds unreadable — callers + /// (Settings > Integrations) must catch it and tell the user, not let it crash the app. + /// Only this call's own read decides, so a read elsewhere can't let it write over the file, + /// and every Update is itself a retry. + /// public static void Update(Action mutate) { - var obj = Read(); + var obj = Read(out var unreadable); + if (unreadable) + throw SettingsFileStore.UnreadableSaveRefused(Path); + mutate(obj); Directory.CreateDirectory(System.IO.Path.GetDirectoryName(Path)!); var json = obj.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); diff --git a/src/PlanViewer.App/Services/SettingsFileStore.cs b/src/PlanViewer.App/Services/SettingsFileStore.cs new file mode 100644 index 00000000..124662fc --- /dev/null +++ b/src/PlanViewer.App/Services/SettingsFileStore.cs @@ -0,0 +1,141 @@ +using System; +using System.Diagnostics; +using System.IO; + +namespace PlanViewer.App.Services; + +/// +/// Shared corrupt/unreadable-file policy for , +/// and . Before this, all three treated +/// "exists but can't be parsed" and "exists but can't be read" the same as "does not exist": +/// defaults came back, and the very next save silently overwrote the user's file with those +/// defaults — for a read failure that might be transient (locked by another process, a network +/// share blip, antivirus), or a parse failure that lost content the user would want back. +/// +/// The two failure modes now diverge. A file that fails to PARSE is quarantined: moved +/// aside to a .bad-<UTC timestamp> sibling so the original bytes are never lost, and +/// the caller starts fresh from defaults exactly as it did before — a save right after is exactly +/// as safe as it always looked. A file that fails to READ is left exactly where it is: the caller +/// gets defaults back for this one read, but must refuse to save over the file until a later read +/// of the same path succeeds, because a locked-out reader can't tell whether what's on disk still +/// deserves to survive. +/// +internal static class SettingsFileStore +{ + internal enum ReadOutcome + { + /// Parsed successfully. + Loaded, + + /// No file at this path. Defaults; saves are allowed exactly as before. + Missing, + + /// + /// The file was read but did not parse — or parsed to the wrong shape (valid JSON, wrong + /// structure; see 's array-vs-object case). Moved aside to + /// a .bad-<timestamp> sibling. Defaults; saves are allowed. + /// + Malformed, + + /// + /// The file exists but could not even be read (locked, permissions) — or a Malformed + /// file's quarantine move itself failed. Defaults; the caller must refuse to save over + /// this path until a later read of it succeeds. + /// + Unreadable, + } + + /// + /// Reads and hands its text to , applying the + /// policy above. should return null (or throw) for content that + /// doesn't parse into a usable — a JSON syntax error and a + /// structurally-valid-but-wrong-shape document (an array where an object was expected, a bare + /// JSON null) are handled identically: neither is data worth keeping in place of the + /// quarantine copy. + /// + /// The file to read. + /// Prefix for the Debug.WriteLine trace — the calling store's name. + /// Parses the file's text into , or returns null/throws on bad content. + /// The parsed value on ; null otherwise. + internal static ReadOutcome Read(string path, string logSource, Func parse, out T? value) + where T : class + { + if (!File.Exists(path)) + { + value = null; + return ReadOutcome.Missing; + } + + string json; + try + { + json = File.ReadAllText(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Debug.WriteLine($"{logSource}: failed to read {path}: {ex.Message}"); + value = null; + return ReadOutcome.Unreadable; + } + + try + { + var parsed = parse(json); + if (parsed != null) + { + value = parsed; + return ReadOutcome.Loaded; + } + } + catch (Exception ex) + { + Debug.WriteLine($"{logSource}: failed to parse {path}: {ex.Message}"); + } + + value = null; + return Quarantine(path, logSource) ? ReadOutcome.Malformed : ReadOutcome.Unreadable; + } + + /// + /// Moves an unparseable file aside so the next save can start clean without losing the + /// original bytes. Returns false (and leaves the file exactly where it was) if the move + /// itself fails — the caller then treats this the same as . + /// + /// Two readers can find the same broken file at once (the UI thread and the MCP + /// server's). The name carries milliseconds and a counter, so they never collide on it, and + /// a reader that finds the file already gone counts it as moved aside: the other reader + /// moved it, and saving over the path is just as safe for both. + /// + internal static bool Quarantine(string path, string logSource) + { + try + { + var stamped = $"{path}.bad-{DateTime.UtcNow:yyyyMMddHHmmssfff}"; + var quarantined = stamped; + for (var n = 1; File.Exists(quarantined); n++) + quarantined = $"{stamped}-{n}"; + + File.Move(path, quarantined, overwrite: false); + Debug.WriteLine($"{logSource}: moved unreadable {path} to {quarantined}"); + return true; + } + catch (Exception ex) when (!File.Exists(path)) + { + Debug.WriteLine($"{logSource}: {path} was already moved aside: {ex.Message}"); + return true; + } + catch (Exception ex) + { + Debug.WriteLine($"{logSource}: could not quarantine {path}: {ex.Message}"); + return false; + } + } + + /// + /// An IOException naming , for the stores whose save methods must + /// refuse to write instead of silently skipping (ConnectionStore.Save, SettingsFile.Update) — + /// their callers need to hear why the save did not happen rather than have it swallowed. + /// + internal static IOException UnreadableSaveRefused(string path) => new( + $"Not saving {path}: it could not be read on the last load, so its on-disk content might not match what is about to be written. Try again once the file is readable."); +} diff --git a/src/PlanViewer.App/SingleInstance.cs b/src/PlanViewer.App/SingleInstance.cs index 625fe7a6..2eb1ea0f 100644 --- a/src/PlanViewer.App/SingleInstance.cs +++ b/src/PlanViewer.App/SingleInstance.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using System.IO.Pipes; using System.Linq; namespace PlanViewer.App; @@ -46,12 +47,42 @@ internal static class SingleInstance internal const string MutexName = "SQLPerformanceStudio_SingleInstance"; /// - /// Escape hatch (#489): skip the single-instance check and run a full second instance. - /// A user who runs two on purpose accepts settings last-write-wins as their informed - /// choice. Stripped from argv before any file-open logic sees it. + /// Escape hatch (#489): open a window of its own instead of handing the launch to the + /// running instance. A user who runs two on purpose accepts settings last-write-wins as + /// their informed choice — but not a duplicated session: the launch claims the slot + /// first, and when another instance already holds it this process runs as a secondary + /// (see ). Stripped from argv before any file-open + /// logic sees it. /// internal const string NewInstanceFlag = "--new-instance"; + /// + /// True in a process started with while another instance + /// already owned the single-instance slot. Set once by before any + /// window exists; nothing else in the product sets it. Tests that set it belong in the + /// serial collection, because it changes what every settings save writes. + /// + /// Why a secondary must not own the session. The saved open-tab list and the + /// scratch buffer folder have one writer by design: the owner rewrites the list on every + /// tab change, names buffers by ids only it knows, and sweeps any buffer its own list + /// does not name. A second process that restored that list would open a copy of every + /// tab, share the owner's buffer ids so that both windows write, drop and sweep the same + /// files, and whichever window closed last would overwrite the other's list. + /// + /// What a secondary does instead. It restores nothing (a file argument still + /// opens; otherwise the usual new tab), never writes the list or a scratch buffer, never + /// sweeps the buffer folder, and keeps the list already on disk when it saves settings + /// (see AppSettingsService.Save). What it loses is session restore for its own + /// tabs: they are not reopened at the next start, whether it closed cleanly or crashed, + /// and its scratch tabs have no crash recovery. The unsaved-changes prompts do not depend + /// on persistence and work as they always have. + /// + /// Not set on the launch path where a non-owner runs fully after the pipe hand-off + /// failed (see Program.Main): that launch never asked for a second window, so it + /// keeps the pre-#489 behavior. + /// + internal static bool IsSecondaryInstance { get; set; } + /// /// The line a bare second launch sends to mean "surface your main window". The /// double-colons make it unrepresentable as a Windows file name on purpose — see the @@ -59,6 +90,25 @@ internal static class SingleInstance /// internal const string ActivateSentinel = "::activate::"; + /// + /// The receiver's end of the pipe. CurrentUserOnly gives the pipe an access list that + /// names only this user, so another account on the machine can neither send it a line + /// nor connect and hold its single server slot. + /// + internal static NamedPipeServerStream CreatePipeServer(string pipeName = PipeName) => + new(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + + /// + /// A sender's end of the pipe. CurrentUserOnly makes Connect check that the pipe it + /// reached was created by this user, so a pipe of the same name that another account + /// created first never receives a path. On Windows the check also compares elevation: + /// an elevated launch does not hand its file to a running instance that is not + /// elevated, and opens its own window instead. + /// + internal static NamedPipeClientStream CreatePipeClient(string pipeName = PipeName) => + new(".", pipeName, PipeDirection.Out, PipeOptions.CurrentUserOnly); + /// What one received pipe line means. See . internal enum PipeMessage { diff --git a/src/PlanViewer.Cli/CliRoot.cs b/src/PlanViewer.Cli/CliRoot.cs new file mode 100644 index 00000000..8beb1553 --- /dev/null +++ b/src/PlanViewer.Cli/CliRoot.cs @@ -0,0 +1,27 @@ +using System.CommandLine; +using PlanViewer.Cli.Commands; +using PlanViewer.Core.Interfaces; + +namespace PlanViewer.Cli; + +public static class CliRoot +{ + /// + /// The System.CommandLine commands: analyze, query-store, and credential where credentials can + /// be stored. Program.cs runs this tree, and the tests parse it, so a command added here is seen + /// by both. + /// + public static RootCommand Create(ICredentialService? credentialService) + { + var root = new RootCommand("SQL Server execution plan analyzer (use 'planview repl' for interactive plan exploration)") + { + AnalyzeCommand.Create(credentialService), + QueryStoreCommand.Create(credentialService), + }; + + if (credentialService != null) + root.Add(CredentialCommand.Create(credentialService)); + + return root; + } +} diff --git a/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs b/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs index ef294fb4..4c542f31 100644 --- a/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs +++ b/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs @@ -31,11 +31,9 @@ public static Command Create(ICredentialService? credentialService = null) Description = "Read plan XML from stdin" }; - var outputOption = new Option("--output", "-o") - { - Description = "Output format: json or text", - DefaultValueFactory = _ => "json" - }; + var outputOption = PlanAnalysisRunner.CreateOutputOption( + "Output format. With --server, both writes a .json and a .txt file per plan. Without --server, both prints json", + defaultFormat: "json"); var compactOption = new Option("--compact") { @@ -134,7 +132,7 @@ public static Command Create(ICredentialService? credentialService = null) { var file = parseResult.GetValue(fileArg); var stdin = parseResult.GetValue(stdinOption); - var output = parseResult.GetValue(outputOption) ?? "json"; + var output = PlanAnalysisRunner.ReadOutputFormat(parseResult, outputOption, "json"); var compact = parseResult.GetValue(compactOption); var warningsOnly = parseResult.GetValue(warningsOnlyOption); var server = parseResult.GetValue(serverOption); @@ -155,23 +153,31 @@ public static Command Create(ICredentialService? credentialService = null) // Load .env file if present (CLI args take precedence) var env = ConnectionHelper.LoadEnvFile(); - server ??= env.GetValueOrDefault("PLANVIEW_SERVER"); - database ??= env.GetValueOrDefault("PLANVIEW_DATABASE"); - login ??= env.GetValueOrDefault("PLANVIEW_LOGIN"); - if (!trustCert && env.GetValueOrDefault("PLANVIEW_TRUST_CERT")?.Equals("true", StringComparison.OrdinalIgnoreCase) == true) - trustCert = true; + if (env.Error is { } envError) + { + Console.Error.WriteLine(envError); + Environment.ExitCode = 1; + return; + } + + var settings = env.Fill(new ConnectionSettings(server, database, login, trustCert)); + (server, database, login, trustCert) = settings; // Resolve password from --password-stdin, --password, or PLANVIEW_PASSWORD // (in that order). --stdin for plan XML conflicts with --password-stdin. if (!PasswordResolver.TryResolve( passwordInline, passwordStdin, stdin, - env.GetValueOrDefault("PLANVIEW_PASSWORD"), + () => env.PasswordFor(settings), out var password)) { Environment.ExitCode = 1; return; } + // A .env file can pick the server and turn off certificate validation, so say when it did. + if (env.Notice is { } envNotice) + Console.Error.WriteLine(envNotice); + if (server != null) { await RunLiveAsync(file, server, database, query, outputDir, estimated, @@ -227,9 +233,10 @@ private static async Task RunAsync(FileInfo? file, bool stdin, string output, bo var plan = PlanAnalysisRunner.Analyze(planXml, analyzerConfig); - if (plan.Batches.Count == 0) + // Covers a plan that failed to parse and a plan with no statements, with the same messages as before. + if (PlanAnalysisRunner.ParseFailure(plan) is { } parseFailure) { - Console.Error.WriteLine("Could not parse any statements from the plan XML"); + Console.Error.WriteLine(parseFailure); Environment.ExitCode = 1; return; } @@ -249,7 +256,18 @@ private static async Task RunAsync(FileInfo? file, bool stdin, string output, bo else { var opts = compact ? CompactJsonOptions : JsonOptions; - Console.WriteLine(JsonSerializer.Serialize(result, opts)); + string json; + try + { + json = PlanAnalysisRunner.SerializeResult(result, opts); + } + catch (InvalidOperationException exception) + { + Console.Error.WriteLine(exception.Message); + Environment.ExitCode = 1; + return; + } + Console.WriteLine(json); } } @@ -424,6 +442,8 @@ private static async Task RunLiveAsync( single-statement query past the showplan cap gets its full text in the output instead of the plan's 4,000-character stub (#502). */ var plan = PlanAnalysisRunner.Analyze(planXml, analyzerConfig, serverMetadata); + if (PlanAnalysisRunner.ParseFailure(plan) is { } parseFailure) + throw new InvalidOperationException(parseFailure); var result = ResultMapper.Map(plan, $"{name}.sql", capturedQueryText: sqlText); await PlanAnalysisRunner.WriteResultFilesAsync( diff --git a/src/PlanViewer.Cli/Commands/CliConnectionResolver.cs b/src/PlanViewer.Cli/Commands/CliConnectionResolver.cs index f8ce0faa..923b76c1 100644 --- a/src/PlanViewer.Cli/Commands/CliConnectionResolver.cs +++ b/src/PlanViewer.Cli/Commands/CliConnectionResolver.cs @@ -70,7 +70,10 @@ deep inside MSAL with "0xwindow_handle_required" — a message that tells the us DisplayName = server, AuthenticationType = authType, TrustServerCertificate = trustCert, - EncryptMode = trustCert ? "Optional" : "Mandatory" + /* Keep encryption mandatory regardless of --trust-cert, as ConnectionHelper does for a + direct login. --trust-cert only skips certificate validation (for self-signed certs); + it must not also make encryption optional and let queries and results cross in plaintext. */ + EncryptMode = "Mandatory" }; } diff --git a/src/PlanViewer.Cli/Commands/PlanAnalysisRunner.cs b/src/PlanViewer.Cli/Commands/PlanAnalysisRunner.cs index 46dce532..86fd85f6 100644 --- a/src/PlanViewer.Cli/Commands/PlanAnalysisRunner.cs +++ b/src/PlanViewer.Cli/Commands/PlanAnalysisRunner.cs @@ -1,3 +1,5 @@ +using System; +using System.CommandLine; using System.IO; using System.Text.Json; using System.Threading.Tasks; @@ -22,15 +24,96 @@ public static class PlanAnalysisRunner public static ParsedPlan Analyze(string planXml, AnalyzerConfig config, ServerMetadata? serverMetadata = null) => PlanAnalysisPipeline.Analyze(planXml, config, serverMetadata); + /// + /// The message to show when the plan XML could not be parsed into a plan with at least one + /// statement, or null when it could. The pipeline skips analysis for such a plan, and whatever + /// parsed before a failure is partial, so writing it out would look like a clean result with no + /// findings. A plan nested deeper than MaxParseDepth is refused this way (#589). So is XML that + /// parses but holds no statement, such as a file that is not a showplan: it used to be caught + /// only by the single-file path, and the live and Query Store paths wrote an empty result for it. + /// + public static string? ParseFailure(ParsedPlan plan) => + !string.IsNullOrWhiteSpace(plan.ParseError) + ? $"Could not parse the plan XML: {plan.ParseError}" + : PlanStatements.NoStatementsMessage(plan); + + /// + /// Serializes an analysis result, and says what went wrong when the operator tree is too + /// deep for AnalysisJson.MaxDepth, instead of the serializer's "possible object cycle" (#589). + /// + public static string SerializeResult(AnalysisResult result, JsonSerializerOptions jsonOptions) + { + try + { + return JsonSerializer.Serialize(result, jsonOptions); + } + catch (JsonException exception) + { + throw new InvalidOperationException( + $"{AnalysisJson.TooDeepMessage} Use --output text, or --warnings-only to leave out the operator tree.", + exception); + } + } + + /// The values --output accepts. + public static readonly IReadOnlyList OutputFormats = new[] { "json", "text", "both" }; + + /// + /// Builds the --output option for a command, so every command that takes it accepts the same + /// values and refuses anything else while the command line is parsed, before the command does + /// any work. An unknown value used to get through: the query-store command and "analyze + /// --server" wrote no files for it and exited 0, and "analyze <file>" printed json. + /// + /// Any letter case is accepted, as --order-by accepts it: "-o JSON" printed json for a + /// single file before, and refusing it now would break a command line that worked. Read the + /// value with , which lowercases it. + /// + public static Option CreateOutputOption(string description, string defaultFormat) + { + var option = new Option("--output", "-o") + { + Description = description, + DefaultValueFactory = _ => defaultFormat + }; + // Help shows from these, and shell completion offers them. + option.CompletionSources.Add(OutputFormats.ToArray()); + option.Validators.Add(result => + { + var value = result.GetValueOrDefault(); + if (value is not null && !OutputFormats.Contains(value.ToLowerInvariant())) + { + result.AddError( + $"Argument '{value}' not recognized for --output. Must be one of: {string.Join(", ", OutputFormats)}"); + } + }); + return option; + } + + /// + /// The --output value in lowercase, the form the commands and + /// compare against. + /// + public static string ReadOutputFormat(ParseResult parseResult, Option option, string defaultFormat) => + (parseResult.GetValue(option) ?? defaultFormat).ToLowerInvariant(); + /// /// Writes {label}.analysis.json and/or {label}.analysis.txt into outDir per /// outputFormat ("json", "text", or "both"), honoring warningsOnly (which - /// drops operator trees from the serialized output). + /// drops operator trees from the serialized output). Refuses any other format + /// before it writes anything or changes the result, so a caller that did not go + /// through cannot get an empty run that looks fine. /// public static async Task WriteResultFilesAsync( AnalysisResult result, string outDir, string label, string outputFormat, JsonSerializerOptions jsonOptions, bool warningsOnly) { + if (!OutputFormats.Contains(outputFormat)) + { + throw new ArgumentException( + $"Unknown output format '{outputFormat}'. Use one of: {string.Join(", ", OutputFormats)}.", + nameof(outputFormat)); + } + if (warningsOnly) { foreach (var stmt in result.Statements) @@ -39,7 +122,7 @@ public static async Task WriteResultFilesAsync( if (outputFormat == "json" || outputFormat == "both") { - var json = JsonSerializer.Serialize(result, jsonOptions); + var json = SerializeResult(result, jsonOptions); await File.WriteAllTextAsync(Path.Combine(outDir, $"{label}.analysis.json"), json); } diff --git a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs index e7b7ee00..aa91dece 100644 --- a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs +++ b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs @@ -10,6 +10,13 @@ namespace PlanViewer.Cli.Commands; public static class QueryStoreCommand { + /// The --order-by values the Query Store query ranks by. + internal static readonly IReadOnlyList OrderByValues = new[] + { + "cpu", "avg-cpu", "duration", "avg-duration", "reads", "avg-reads", "writes", "avg-writes", + "physical-reads", "avg-physical-reads", "memory", "avg-memory", "executions" + }; + /* #430: these two were built inline and never got the depth ceiling, so `querystore` still failed on a plan deeper than ~30 operators long after the crash was "fixed" — one ERROR row in summary.txt per deep plan, which is quieter than the crash and no more correct. */ @@ -39,10 +46,24 @@ public static Command Create(ICredentialService? credentialService = null) var orderByOption = new Option("--order-by") { - Description = "Ranking metric (total or avg): cpu, avg-cpu, duration, avg-duration, reads, avg-reads, writes, avg-writes, physical-reads, avg-physical-reads, memory, avg-memory, executions", + Description = $"Ranking metric (total or avg): {string.Join(", ", OrderByValues)}", DefaultValueFactory = _ => "cpu" }; + /* The query lowercases the value and falls back to CPU order for one it does not know, so a + misspelled metric used to run to the end ranked by CPU, with the summary still saying "top + by ". Refuse it while the command line is parsed, as --output does. Any + letter case is accepted, because the query accepts it. */ + orderByOption.Validators.Add(result => + { + var value = result.GetValueOrDefault(); + if (value is not null && !OrderByValues.Contains(value.ToLowerInvariant())) + { + result.AddError( + $"Argument '{value}' not recognized for --order-by. Must be one of: {string.Join(", ", OrderByValues)}"); + } + }); + var hoursBackOption = new Option("--hours-back") { Description = "Hours of history to analyze", @@ -54,11 +75,9 @@ public static Command Create(ICredentialService? credentialService = null) Description = "Directory for output files (default: current directory)" }; - var outputOption = new Option("--output", "-o") - { - Description = "Output format: json or text", - DefaultValueFactory = _ => "text" - }; + var outputOption = PlanAnalysisRunner.CreateOutputOption( + "Output format for each plan's file. both writes a .json and a .txt file per plan", + defaultFormat: "text"); var compactOption = new Option("--compact") { @@ -199,7 +218,7 @@ public static Command Create(ICredentialService? credentialService = null) var orderBy = parseResult.GetValue(orderByOption) ?? "cpu"; var hoursBack = parseResult.GetValue(hoursBackOption); var outputDir = parseResult.GetValue(outputDirOption); - var output = parseResult.GetValue(outputOption) ?? "text"; + var output = PlanAnalysisRunner.ReadOutputFormat(parseResult, outputOption, "text"); var compact = parseResult.GetValue(compactOption); var warningsOnly = parseResult.GetValue(warningsOnlyOption); var configPath = parseResult.GetValue(configOption); @@ -227,20 +246,32 @@ public static Command Create(ICredentialService? credentialService = null) // Load .env file if present (CLI args take precedence) var env = ConnectionHelper.LoadEnvFile(); - login ??= env.GetValueOrDefault("PLANVIEW_LOGIN"); - if (!trustCert && env.GetValueOrDefault("PLANVIEW_TRUST_CERT")?.Equals("true", StringComparison.OrdinalIgnoreCase) == true) - trustCert = true; + if (env.Error is { } envError) + { + Console.Error.WriteLine(envError); + Environment.ExitCode = 1; + return; + } + + // --server and --database are required, so the file only fills the login and trust-cert. + var settings = env.Fill(new ConnectionSettings(server, database, login, trustCert)); + login = settings.Login; + trustCert = settings.TrustCert; // Resolve password from --password-stdin, --password, or PLANVIEW_PASSWORD if (!PasswordResolver.TryResolve( passwordInline, passwordStdin, stdinAlreadyClaimed: false, - env.GetValueOrDefault("PLANVIEW_PASSWORD"), + () => env.PasswordFor(settings), out var password)) { Environment.ExitCode = 1; return; } + // A .env file can turn off certificate validation, so say when it did. + if (env.Notice is { } envNotice) + Console.Error.WriteLine(envNotice); + if (top < 1) { Console.Error.WriteLine("--top must be >= 1"); @@ -394,6 +425,36 @@ private static async Task RunAsync( var outDir = outputDir?.FullName ?? Directory.GetCurrentDirectory(); Directory.CreateDirectory(outDir); + var failed = await AnalyzePlansAsync( + plans, database, orderBy, hoursBack, outDir, outputFormat, compact, warningsOnly, + analyzerConfig, serverMetadata, Console.Error); + + Console.Error.WriteLine(); + if (plans.Count > 1) + Console.Error.WriteLine($"Processed {plans.Count} plans: {plans.Count - failed} succeeded, {failed} failed"); + Console.Error.WriteLine($"Output: {outDir}"); + Console.Error.WriteLine($"Summary: {Path.Combine(outDir, SummaryFileName)}"); + + /* Same as "analyze --server": the plans that worked keep their files and their rows in the + summary, and the exit code says the run was incomplete. It used to exit 0 no matter how + many plans failed, so a script or a CI job could not tell. */ + if (failed > 0) + Environment.ExitCode = 1; + } + + internal const string SummaryFileName = "summary.txt"; + + /// + /// Analyzes each fetched plan, writes its .sqlplan and analysis files, then writes summary.txt. + /// Returns how many plans failed. A failed plan gets an ERROR row in the summary and does not stop + /// the plans after it, so every plan that worked still has its files. Progress goes to + /// , which is stderr for the command. + /// + internal static async Task AnalyzePlansAsync( + IReadOnlyList plans, string database, string orderBy, int hoursBack, + string outDir, string outputFormat, bool compact, bool warningsOnly, + AnalyzerConfig analyzerConfig, ServerMetadata? serverMetadata, TextWriter log) + { // Summary tracking — show the primary sort metric column var (metricHeader, metricFmt) = GetMetricFormatter(orderBy); @@ -404,6 +465,7 @@ private static async Task RunAsync( "#", "Query ID", "Plan ID", "Query Hash", "Module", metricHeader, "Executions", "Warns", "Crit")); summaryLines.Add(new string('-', 130)); + var failed = 0; for (int i = 0; i < plans.Count; i++) { var qsPlan = plans[i]; @@ -411,7 +473,7 @@ private static async Task RunAsync( try { - Console.Error.Write($"[{i + 1}/{plans.Count}] Query {qsPlan.QueryId} / Plan {qsPlan.PlanId}... "); + log.Write($"[{i + 1}/{plans.Count}] Query {qsPlan.QueryId} / Plan {qsPlan.PlanId}... "); // Save .sqlplan var planPath = Path.Combine(outDir, $"{label}.sqlplan"); @@ -421,6 +483,8 @@ private static async Task RunAsync( past the showplan cap gets its complete text in the output (#502) — the same hand-off the MCP Query Store path makes. */ var plan = PlanAnalysisRunner.Analyze(qsPlan.PlanXml, analyzerConfig, serverMetadata); + if (PlanAnalysisRunner.ParseFailure(plan) is { } parseFailure) + throw new InvalidOperationException(parseFailure); var result = ResultMapper.Map(plan, $"{label}.sqlplan", capturedQueryText: qsPlan.QueryText); await PlanAnalysisRunner.WriteResultFilesAsync( @@ -436,11 +500,12 @@ await PlanAnalysisRunner.WriteResultFilesAsync( moduleName.Length > 18 ? moduleName[..18] + ".." : moduleName, metricValue, qsPlan.CountExecutions, warnings, critical)); - Console.Error.WriteLine($"OK ({warnings} warnings, {critical} critical)"); + log.WriteLine($"OK ({warnings} warnings, {critical} critical)"); } catch (Exception ex) { - Console.Error.WriteLine($"ERROR: {ex.Message}"); + failed++; + log.WriteLine($"ERROR: {ex.Message}"); summaryLines.Add(string.Format(" {0,-4} {1,-10} {2,-10} {3,-20} {4,-20} ERROR: {5}", i + 1, qsPlan.QueryId, qsPlan.PlanId, qsPlan.QueryHash, "", ex.Message)); } @@ -448,12 +513,9 @@ await PlanAnalysisRunner.WriteResultFilesAsync( // Write summary summaryLines.Add(""); - var summaryPath = Path.Combine(outDir, "summary.txt"); - await File.WriteAllLinesAsync(summaryPath, summaryLines); + await File.WriteAllLinesAsync(Path.Combine(outDir, SummaryFileName), summaryLines); - Console.Error.WriteLine(); - Console.Error.WriteLine($"Output: {outDir}"); - Console.Error.WriteLine($"Summary: {summaryPath}"); + return failed; } private static (string Header, Func Format) GetMetricFormatter(string orderBy) diff --git a/src/PlanViewer.Cli/ConnectionHelper.cs b/src/PlanViewer.Cli/ConnectionHelper.cs index 03586697..8d946ca4 100644 --- a/src/PlanViewer.Cli/ConnectionHelper.cs +++ b/src/PlanViewer.Cli/ConnectionHelper.cs @@ -29,13 +29,15 @@ public static string BuildConnectionString( return builder.ConnectionString; } - public static Dictionary LoadEnvFile() + public static EnvFile LoadEnvFile() => LoadEnvFile(Directory.GetCurrentDirectory()); + + public static EnvFile LoadEnvFile(string directory) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); - var envPath = Path.Combine(Directory.GetCurrentDirectory(), ".env"); + var envPath = Path.GetFullPath(Path.Combine(directory, ".env")); if (!File.Exists(envPath)) - return result; + return new EnvFile(null, result); foreach (var line in File.ReadAllLines(envPath)) { @@ -61,6 +63,6 @@ public static Dictionary LoadEnvFile() result[key] = value; } - return result; + return new EnvFile(envPath, result); } } diff --git a/src/PlanViewer.Cli/EnvFile.cs b/src/PlanViewer.Cli/EnvFile.cs new file mode 100644 index 00000000..12e68ccb --- /dev/null +++ b/src/PlanViewer.Cli/EnvFile.cs @@ -0,0 +1,97 @@ +namespace PlanViewer.Cli; + +/// The connection settings that a command line gave, or those after the .env file filled the gaps. +public sealed record ConnectionSettings(string? Server, string? Database, string? Login, bool TrustCert); + +/// +/// Connection settings from the .env file in the working directory. Command-line options win over +/// the file, so asks the file only for what the command line left out, and the +/// file records each key it supplied. A .env file can pick the server and turn off certificate +/// validation, so names the settings it supplied instead of applying them +/// without a word. +/// +public sealed class EnvFile +{ + private readonly Dictionary _values; + private readonly List _used = []; + + public EnvFile(string? filePath, Dictionary values) + { + FilePath = filePath; + _values = values; + + /* A value with a control character could move the cursor and erase the notice, and no real + setting needs one. Tab is allowed because it cannot reach another line. */ + var badKey = values.FirstOrDefault(kv => + kv.Key.StartsWith("PLANVIEW_", StringComparison.OrdinalIgnoreCase) && + kv.Value.Any(c => char.IsControl(c) && c != '\t')).Key; + if (badKey != null) + Error = $"The value of {Printable(badKey)} in {Printable(filePath ?? ".env")} has a control character. Fix the value or delete the file."; + } + + /// The full path of the file, or null when the directory has no .env file. + public string? FilePath { get; } + + /// A line for stderr when the file must not be used, or null. The command stops. + public string? Error { get; } + + /// + /// Fills the settings that the command line left out. With no server there is nothing to + /// connect to, so the other settings would have no effect and the file is not asked for them. + /// + public ConnectionSettings Fill(ConnectionSettings commandLine) + { + var server = commandLine.Server ?? Use("PLANVIEW_SERVER"); + if (server is null) + return commandLine; + + return new ConnectionSettings( + server, + commandLine.Database ?? Use("PLANVIEW_DATABASE"), + commandLine.Login ?? Use("PLANVIEW_LOGIN"), + commandLine.TrustCert || UseFlag("PLANVIEW_TRUST_CERT")); + } + + /// + /// The file's password for a SQL login. Without a login the commands use the credential store or + /// Windows authentication and ignore any password, so the file is not asked for one. + /// + public string? PasswordFor(ConnectionSettings settings) => + settings.Server is not null && !string.IsNullOrEmpty(settings.Login) ? Use("PLANVIEW_PASSWORD") : null; + + /// + /// One line for stderr that names the file and the keys it supplied, or null when it supplied + /// none. It never includes a value, so a password in the file stays out of the output. + /// + public string? Notice => + _used.Count == 0 ? null : $"Using settings from {Printable(FilePath ?? ".env")}: {string.Join(", ", _used)}"; + + private string? Use(string key) + { + if (!_values.TryGetValue(key, out var value)) + return null; + + Record(key); + return value; + } + + /* Only "true" changes the setting, so only then is the key recorded. */ + private bool UseFlag(string key) + { + if (!_values.TryGetValue(key, out var value) || !value.Equals("true", StringComparison.OrdinalIgnoreCase)) + return false; + + Record(key); + return true; + } + + private void Record(string key) + { + if (!_used.Contains(key, StringComparer.OrdinalIgnoreCase)) + _used.Add(key); + } + + /* A path or key from the file goes to the terminal, so a control character in it is shown as '?'. */ + private static string Printable(string text) => + string.Concat(text.Select(c => char.IsControl(c) ? '?' : c)); +} diff --git a/src/PlanViewer.Cli/PasswordResolver.cs b/src/PlanViewer.Cli/PasswordResolver.cs index ed50bb11..843f1132 100644 --- a/src/PlanViewer.Cli/PasswordResolver.cs +++ b/src/PlanViewer.Cli/PasswordResolver.cs @@ -5,8 +5,8 @@ namespace PlanViewer.Cli; /// 1. --password-stdin (reads one line from redirected stdin) /// 2. --password (inline CLI arg; emits a stderr warning because it's /// visible in process listings, shell history, and audit logs) -/// 3. PLANVIEW_PASSWORD environment variable (from the process environment or -/// a .env file, already looked up by the caller) +/// 3. PLANVIEW_PASSWORD from the .env file (asked for only when neither option +/// above gave a password, so the caller knows whether the file supplied it) /// internal static class PasswordResolver { @@ -14,26 +14,29 @@ internal static class PasswordResolver /// Returns true with a resolved password (which may be null if no source provided /// one). Returns false on user error (mutual-exclusion violation or stdin not /// redirected when --password-stdin was requested). The caller is responsible - /// for setting Environment.ExitCode on failure. + /// for setting Environment.ExitCode on failure. Messages go to , + /// or to stderr when it is null. /// public static bool TryResolve( string? inlinePassword, bool passwordFromStdin, bool stdinAlreadyClaimed, - string? envPassword, - out string? password) + Func envPassword, + out string? password, + TextWriter? error = null) { password = null; + var err = error ?? Console.Error; if (passwordFromStdin && !string.IsNullOrEmpty(inlinePassword)) { - Console.Error.WriteLine("--password and --password-stdin are mutually exclusive."); + err.WriteLine("--password and --password-stdin are mutually exclusive."); return false; } if (passwordFromStdin && stdinAlreadyClaimed) { - Console.Error.WriteLine("--password-stdin can't be combined with --stdin (both read from stdin)."); + err.WriteLine("--password-stdin can't be combined with --stdin (both read from stdin)."); return false; } @@ -41,7 +44,7 @@ public static bool TryResolve( { if (!Console.IsInputRedirected) { - Console.Error.WriteLine("--password-stdin requires stdin to be redirected (pipe the password into the command)."); + err.WriteLine("--password-stdin requires stdin to be redirected (pipe the password into the command)."); return false; } password = Console.In.ReadLine()?.TrimEnd('\r', '\n') ?? ""; @@ -50,14 +53,14 @@ public static bool TryResolve( if (!string.IsNullOrEmpty(inlinePassword)) { - Console.Error.WriteLine( + err.WriteLine( "Warning: --password is visible in process listings and shell history. " + - "Prefer --password-stdin, the PLANVIEW_PASSWORD env var, or the credential store."); + "Prefer --password-stdin, PLANVIEW_PASSWORD in a .env file, or the credential store."); password = inlinePassword; return true; } - password = envPassword; + password = envPassword(); return true; } } diff --git a/src/PlanViewer.Cli/PlanViewer.Cli.csproj b/src/PlanViewer.Cli/PlanViewer.Cli.csproj index 70ed2df7..9b0ab6a0 100644 --- a/src/PlanViewer.Cli/PlanViewer.Cli.csproj +++ b/src/PlanViewer.Cli/PlanViewer.Cli.csproj @@ -18,9 +18,9 @@ - - - + + + diff --git a/src/PlanViewer.Cli/Program.cs b/src/PlanViewer.Cli/Program.cs index f0b451f2..ada704d0 100644 --- a/src/PlanViewer.Cli/Program.cs +++ b/src/PlanViewer.Cli/Program.cs @@ -1,6 +1,5 @@ using System.CommandLine; using PlanViewer.Cli; -using PlanViewer.Cli.Commands; using PlanViewer.Core.Services; using PlanViewer.Core.Interfaces; using PlanViewer.Cli.ReplSurface; @@ -22,14 +21,7 @@ // Credential storage not available — analyze-only mode still works } -var root = new RootCommand("SQL Server execution plan analyzer (use 'planview repl' for interactive plan exploration)") -{ - AnalyzeCommand.Create(credentialService), - QueryStoreCommand.Create(credentialService), -}; - -if (credentialService != null) - root.Add(CredentialCommand.Create(credentialService)); +var root = CliRoot.Create(credentialService); // System.CommandLine's InvokeAsync returns 0 for successful dispatch even when a // handler set Environment.ExitCode = 1 to signal a validation error. Honor either diff --git a/src/PlanViewer.Core/Models/PlanModels.cs b/src/PlanViewer.Core/Models/PlanModels.cs index 26746063..df5b4bd7 100644 --- a/src/PlanViewer.Core/Models/PlanModels.cs +++ b/src/PlanViewer.Core/Models/PlanModels.cs @@ -198,6 +198,7 @@ public class PlanNode // Detail properties (for tooltip/properties panel) public string? DatabaseName { get; set; } + public string? SchemaName { get; set; } public string? ObjectName { get; set; } public string? FullObjectName { get; set; } public string? IndexName { get; set; } @@ -412,6 +413,17 @@ public class PlanWarning /// public PlanWarningSource Source { get; set; } = PlanWarningSource.PerformanceStudio; + /// + /// The analyzer rule that produced this finding, set where the rule emits it (#575). Null for + /// anything no numbered rule produced: the engine's own warnings and the wait-stats findings. + /// + /// Severity overrides key on it. They used to map WarningType back to a rule through a + /// partial name match against a rule-to-name table, and that table had no entry for rules + /// 34-37 and 39, for two of rule 30's three finding types, or for rule 10's RID Lookup, so + /// overrides for those were silently ignored. + /// + public int? RuleNumber { get; set; } + /// /// The operators this finding actually came from, so a reader can be taken to them (#440). /// @@ -468,6 +480,13 @@ public class MemoryGrantInfo public long RequestedMemoryKB { get; set; } public long GrantedMemoryKB { get; set; } public long MaxUsedMemoryKB { get; set; } + /// + /// True when the plan XML carried a MaxUsedMemory attribute. An actual plan does, and there 0 + /// means the query used none of its grant. An estimated plan, or a plan with no runtime grant + /// info, does not, and there is 0 only because nothing was + /// reported. A rule that acts on "used nothing" must check this first. + /// + public bool HasMaxUsedMemory { get; set; } public long GrantWaitTimeMs { get; set; } public long LastRequestedMemoryKB { get; set; } public string? IsMemoryGrantFeedbackAdjusted { get; set; } diff --git a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs index e89b5b77..eae266b9 100644 --- a/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs +++ b/src/PlanViewer.Core/Models/QueryStoreHistoryRow.cs @@ -43,6 +43,14 @@ public class QueryStoreHistoryRow public string TotalLogicalWritesDisplay => TotalLogicalWrites.ToString("N0"); public string TotalPhysicalReadsDisplay => TotalPhysicalReads.ToString("N0"); public string TotalMemoryMbDisplay => TotalMemoryMb.ToString("N2"); - public string IntervalStartLocal => TimeDisplayHelper.FormatForDisplay(IntervalStartUtc); - public string LastExecutionLocal => LastExecutionUtc.HasValue ? TimeDisplayHelper.FormatForDisplay(LastExecutionUtc.Value) : ""; + + /// + /// The offset holder of the connection this row was fetched on. Set by whoever fetched it + /// (the History control), which keeps this model free of anything from the app. Unset reads + /// as zero, which is UTC in Server mode. + /// + public ServerUtcOffset? ServerOffset { get; set; } + + public string IntervalStartLocal => TimeDisplayHelper.FormatForDisplay(IntervalStartUtc, ServerOffset?.Minutes ?? 0); + public string LastExecutionLocal => LastExecutionUtc.HasValue ? TimeDisplayHelper.FormatForDisplay(LastExecutionUtc.Value, ServerOffset?.Minutes ?? 0) : ""; } diff --git a/src/PlanViewer.Core/Output/AnalysisJson.cs b/src/PlanViewer.Core/Output/AnalysisJson.cs index 884f0f70..0a48f641 100644 --- a/src/PlanViewer.Core/Output/AnalysisJson.cs +++ b/src/PlanViewer.Core/Output/AnalysisJson.cs @@ -35,6 +35,15 @@ public static class AnalysisJson /// Depth ceiling for every serializer that writes an . public const int MaxDepth = 1024; + /// + /// What to tell the user when writing an analysis fails with a . The + /// operator tree has no cycles (see above), so the cause is depth: the parser accepts plans up to + /// 1,000 operator levels, and each level costs two JSON levels. The serializer's own message + /// blames "a possible object cycle", which sends the reader the wrong way (#589). + /// + public const string TooDeepMessage = + "The plan is too deeply nested to write as JSON (more than about 500 operator levels)."; + /// /// The indented options the UI writes advice with. Matches what the call sites built inline before /// #430 — WriteIndented only — so the emitted JSON is unchanged apart from no longer failing diff --git a/src/PlanViewer.Core/Output/AnalysisResult.cs b/src/PlanViewer.Core/Output/AnalysisResult.cs index 796f7dee..e8fa75ab 100644 --- a/src/PlanViewer.Core/Output/AnalysisResult.cs +++ b/src/PlanViewer.Core/Output/AnalysisResult.cs @@ -405,6 +405,13 @@ public class OperatorResult [JsonPropertyName("actual_executions")] public long? ActualExecutions { get; set; } + /// + /// The estimate to set beside : estimated rows times actual executions + /// on the inner side of a Nested Loops join, estimated rows everywhere else (#594). + /// + [JsonPropertyName("expected_rows")] + public double? ExpectedRows { get; set; } + [JsonPropertyName("actual_elapsed_ms")] public long? ActualElapsedMs { get; set; } diff --git a/src/PlanViewer.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index 4d2b1342..c1648971 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -1,6 +1,8 @@ using System.IO; +using System.Runtime.CompilerServices; using System.Text; using System.Web; +using PlanViewer.Core.Services; namespace PlanViewer.Core.Output; @@ -291,7 +293,7 @@ private static void WriteStatement(StringBuilder sb, AnalysisResult result, Stat { sb.AppendLine("
"); sb.AppendLine("

Operator Tree

"); - WriteOperatorNode(sb, stmt.OperatorTree, stmt); + WriteOperatorNode(sb, stmt.OperatorTree); sb.AppendLine("
"); } @@ -307,8 +309,9 @@ private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt) sb.AppendLine("
"); // Order per Joe (#215 E11): Elapsed → CPU:Elapsed → DOP → CPU → Compile → - // Memory → Used → Optimization → CE Model → Cost. Puts the important - // measurements on top and groups related metrics together. + // Memory → Used → CE Model → Optimization → Cost. Puts the important + // measurements on top and groups related metrics together. #613 moved CE Model + // above Optimization, the same order as the App's Runtime Summary. if (stmt.QueryTime != null) { WriteRow(sb, "Elapsed", $"{stmt.QueryTime.ElapsedTimeMs:N0} ms"); @@ -342,10 +345,10 @@ private static void WriteRuntimeCard(StringBuilder sb, StatementResult stmt) if (stmt.MemoryGrant.SerialRequiredKB > 0 && stmt.MemoryGrant.SerialRequiredKB != stmt.MemoryGrant.DesiredKB) WriteRow(sb, "Serial required", FormatKB(stmt.MemoryGrant.SerialRequiredKB)); } - if (stmt.OptimizationLevel != null) - WriteRow(sb, "Optimization", Encode(stmt.OptimizationLevel)); if (stmt.CardinalityEstimationModel > 0) WriteRow(sb, "CE Model", stmt.CardinalityEstimationModel.ToString()); + if (stmt.OptimizationLevel != null) + WriteRow(sb, "Optimization", Encode(stmt.OptimizationLevel)); WriteRow(sb, "Cost", stmt.EstimatedCost.ToString("N2")); sb.AppendLine("
"); sb.AppendLine(""); @@ -509,9 +512,11 @@ private static void WriteWarnings(StringBuilder sb, StatementResult stmt) foreach (var w in sorted) { - var sevLower = w.Severity.ToLowerInvariant(); - sb.AppendLine($"
"); - sb.AppendLine($"{Encode(w.Severity)}"); + // A shared plan's analysis is caller-supplied JSON, so Severity can hold anything. + // Only a fixed class name goes into the attributes; the text itself is encoded. + var sevClass = SeverityClass(w.Severity); + sb.AppendLine($"
"); + sb.AppendLine($"{Encode(w.Severity)}"); if (w.Operator != null) sb.AppendLine($"{Encode(w.Operator)}"); sb.AppendLine($"{Encode(w.Type)}"); @@ -527,7 +532,27 @@ private static void WriteWarnings(StringBuilder sb, StatementResult stmt) sb.AppendLine("
"); } - private static void WriteOperatorNode(StringBuilder sb, OperatorResult node, StatementResult stmt) + private static void WriteOperatorNode(StringBuilder sb, OperatorResult node) + { + WriteOperatorLine(sb, node); + + // Children + if (node.Children.Count > 0) + { + sb.AppendLine("
"); + foreach (var child in node.Children) + WriteOperatorNode(sb, child); + sb.AppendLine("
"); + } + + sb.AppendLine("
"); + } + + /* #589: kept out of WriteOperatorNode so that the recursion's frame stays small. The + interpolated strings here take most of the stack, and now they take it once, not once per + operator level. */ + [MethodImpl(MethodImplOptions.NoInlining)] + private static void WriteOperatorLine(StringBuilder sb, OperatorResult node) { var classes = "op-node"; if (node.CostPercent >= 25) classes += " expensive"; @@ -549,10 +574,12 @@ private static void WriteOperatorNode(StringBuilder sb, OperatorResult node, Sta // Rows if (node.ActualRows.HasValue) { - var est = node.EstimatedRows; - var ratio = est > 0 ? (double)node.ActualRows.Value / est : 0; - var accuracy = est > 0 ? $" ({ratio * 100:F0}%)" : ""; - sb.Append($" {node.ActualRows.Value:N0} of {est:N0} rows{accuracy}"); + // #594: the same execution-aware estimate the plan viewer's node label shows. + // #611: and the same printed numbers, so they agree with the percentage. + var est = node.ExpectedRows ?? node.EstimatedRows; + var (actualText, expectedText, percent) = PlanRowAccuracy.PrintActualOfExpected(node.ActualRows.Value, est); + var accuracy = percent != null ? $" ({percent}%)" : ""; + sb.Append($" {actualText} of {expectedText} rows{accuracy}"); } else { @@ -568,17 +595,6 @@ private static void WriteOperatorNode(StringBuilder sb, OperatorResult node, Sta sb.Append($" {Encode(node.ObjectName)}"); sb.AppendLine(); - - // Children - if (node.Children.Count > 0) - { - sb.AppendLine("
"); - foreach (var child in node.Children) - WriteOperatorNode(sb, child, stmt); - sb.AppendLine("
"); - } - - sb.AppendLine(""); } private static void WriteTextAnalysis(StringBuilder sb, string textOutput) @@ -619,4 +635,15 @@ private static string FormatKB(long kb) } private static string Encode(string text) => HttpUtility.HtmlEncode(text); + + /// + /// Maps a warning severity to one of the stylesheet's three class names. Any other value, + /// null included, gets "info", so severity text never reaches a class attribute. + /// + private static string SeverityClass(string? severity) => severity?.ToLowerInvariant() switch + { + "critical" => "critical", + "warning" => "warning", + _ => "info" + }; } diff --git a/src/PlanViewer.Core/Output/ResultMapper.cs b/src/PlanViewer.Core/Output/ResultMapper.cs index 4a9b3970..24983ddd 100644 --- a/src/PlanViewer.Core/Output/ResultMapper.cs +++ b/src/PlanViewer.Core/Output/ResultMapper.cs @@ -309,7 +309,28 @@ describe the one thing this result no longer shows — a shortened statement — return result; } - private static OperatorResult MapNode(PlanNode node, CancellationToken cancellationToken) + private static OperatorResult MapNode(PlanNode root, CancellationToken cancellationToken) + { + /* #589: a loop with its own stack. Recursion here used about 1 KB of stack per operator + level, so a plan near MaxParseDepth (1,000) needed most of a 1 MB thread, the size the CLI + and the UI run on. Each node's children are still added in plan order. */ + var rootResult = MapSingleNode(root, cancellationToken); + var pending = new Stack<(PlanNode Node, OperatorResult Result)>(); + pending.Push((root, rootResult)); + while (pending.Count > 0) + { + var (node, result) = pending.Pop(); + foreach (var child in node.Children) + { + var childResult = MapSingleNode(child, cancellationToken); + result.Children.Add(childResult); + pending.Push((child, childResult)); + } + } + return rootResult; + } + + private static OperatorResult MapSingleNode(PlanNode node, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var result = new OperatorResult @@ -345,6 +366,7 @@ private static OperatorResult MapNode(PlanNode node, CancellationToken cancellat { result.ActualRows = node.ActualRows; result.ActualExecutions = node.ActualExecutions; + result.ExpectedRows = RowEstimateHelper.GetExpectedRows(node); result.ActualElapsedMs = node.ActualElapsedMs; result.ActualCpuMs = node.ActualCPUMs; result.ActualLogicalReads = node.ActualLogicalReads; @@ -369,10 +391,6 @@ private static OperatorResult MapNode(PlanNode node, CancellationToken cancellat }); } - // Children - foreach (var child in node.Children) - result.Children.Add(MapNode(child, cancellationToken)); - return result; } diff --git a/src/PlanViewer.Core/PlanViewer.Core.csproj b/src/PlanViewer.Core/PlanViewer.Core.csproj index 50104900..28601ca9 100644 --- a/src/PlanViewer.Core/PlanViewer.Core.csproj +++ b/src/PlanViewer.Core/PlanViewer.Core.csproj @@ -8,10 +8,10 @@ - - - - + + + + diff --git a/src/PlanViewer.Core/Services/EstimatedPlanExecutor.cs b/src/PlanViewer.Core/Services/EstimatedPlanExecutor.cs index 17089794..709be342 100644 --- a/src/PlanViewer.Core/Services/EstimatedPlanExecutor.cs +++ b/src/PlanViewer.Core/Services/EstimatedPlanExecutor.cs @@ -84,14 +84,14 @@ public static class EstimatedPlanExecutor internal static string MergeShowPlanXmls(List planXmls) { XNamespace ns = "http://schemas.microsoft.com/sqlserver/2004/07/showplan"; - var baseDoc = XDocument.Parse(planXmls[0]); + var baseDoc = PlanXml.Parse(planXmls[0]); var batchSequence = baseDoc.Root!.Element(ns + "BatchSequence"); if (batchSequence == null) return planXmls[0]; for (int i = 1; i < planXmls.Count; i++) { - var doc = XDocument.Parse(planXmls[i]); + var doc = PlanXml.Parse(planXmls[i]); var batches = doc.Root!.Element(ns + "BatchSequence")?.Elements(ns + "Batch"); if (batches != null) { diff --git a/src/PlanViewer.Core/Services/KeychainCredentialService.cs b/src/PlanViewer.Core/Services/KeychainCredentialService.cs index b534d630..7d6546a5 100644 --- a/src/PlanViewer.Core/Services/KeychainCredentialService.cs +++ b/src/PlanViewer.Core/Services/KeychainCredentialService.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text; using System.Text.RegularExpressions; using PlanViewer.Core.Interfaces; @@ -6,50 +7,83 @@ namespace PlanViewer.Core.Services; /// /// macOS Keychain implementation of ICredentialService. -/// Shells out to /usr/bin/security for generic-password operations. +/// Shells out to /usr/bin/security for generic-password operations. A save sends its command +/// to security -i on stdin, so the password is never in a process's argument list. +/// +/// Calling the Security framework directly would also keep it off the command line, but +/// the keychain lets only the app that created an item read its password without asking. Every +/// item saved so far was created by /usr/bin/security, so reading them from this process would +/// show a keychain prompt for each one. Staying with /usr/bin/security keeps both old and new +/// items readable without a prompt. /// public class KeychainCredentialService : ICredentialService { private const string ServicePrefix = "PlanViewer"; + private const string DefaultSecurityPath = "/usr/bin/security"; + + // `security -i` reads each line into a 4096-byte buffer and runs whatever doesn't fit as a + // separate command. 4094 bytes plus the newline is the longest line it reads whole. + private const int MaxInteractiveLineBytes = 4094; + + private readonly string? _keychainPath; + private readonly string _securityPath = DefaultSecurityPath; + + public KeychainCredentialService() { } + + /// + /// For tests: works in one keychain file instead of the user's default keychain, and runs + /// in place of /usr/bin/security. + /// + internal KeychainCredentialService(string keychainPath, string securityPath = DefaultSecurityPath) + { + _keychainPath = keychainPath; + _securityPath = securityPath; + } private static string ServiceName(string serverId) => $"{ServicePrefix}:{serverId}"; public bool SaveCredential(string serverId, string username, string password) { - var (exitCode, _) = RunSecurity( + // -X takes the password as hex, so it needs no quoting whatever characters it holds. + var line = InteractiveLine(Command( "add-generic-password", "-s", ServiceName(serverId), "-a", username, - "-w", password, - "-U"); + "-X", Convert.ToHexStringLower(Encoding.UTF8.GetBytes(password)), + "-U")); + if (line == null) return false; + + var (exitCode, _, _) = RunSecurity(["-i"], stdin: line); return exitCode == 0; } public (string Username, string Password)? GetCredential(string serverId) { - var service = ServiceName(serverId); - - var (exitCode, output) = RunSecurity("find-generic-password", "-s", service); + // -g prints the attributes on stdout and the password on stderr, in a form that + // decodes exactly. -w prints hex for a password with any byte outside printable ASCII, + // with nothing to tell it apart from a password that is itself hex digits. + var (exitCode, stdout, stderr) = RunSecurity(Command("find-generic-password", "-g", "-s", ServiceName(serverId))); if (exitCode != 0) return null; - var username = ParseAccount(output); + var username = ReadAttribute(stdout, "acct"); if (username == null) return null; - var (pwExit, password) = RunSecurity("find-generic-password", "-s", service, "-w"); - if (pwExit != 0) return null; + var match = Regex.Match(stderr, "^password: (.*)$", RegexOptions.Multiline); + var password = match.Success ? DecodePrintedValue(match.Groups[1].Value) : null; + if (password == null) return null; - return (username, password.Trim()); + return (username, password); } public bool DeleteCredential(string serverId) { - var (exitCode, _) = RunSecurity("delete-generic-password", "-s", ServiceName(serverId)); + var (exitCode, _, _) = RunSecurity(Command("delete-generic-password", "-s", ServiceName(serverId))); return exitCode == 0; } public bool CredentialExists(string serverId) { - var (exitCode, _) = RunSecurity("find-generic-password", "-s", ServiceName(serverId)); + var (exitCode, _, _) = RunSecurity(Command("find-generic-password", "-s", ServiceName(serverId))); return exitCode == 0; } @@ -61,7 +95,7 @@ public bool UpdateCredential(string serverId, string username, string password) ///
public IReadOnlyList<(string ServerName, string Username)> ListAll() { - var (exitCode, output) = RunSecurity("dump-keychain"); + var (exitCode, output, _) = RunSecurity(Command("dump-keychain")); if (exitCode != 0) return []; var results = new List<(string, string)>(); @@ -69,31 +103,82 @@ public bool UpdateCredential(string serverId, string username, string password) foreach (var entry in entries) { - var svcMatch = Regex.Match(entry, @"""svce""=""" + Regex.Escape(ServicePrefix) + @":(.+?)"""); - if (!svcMatch.Success) continue; + var service = ReadAttribute(entry, "svce"); + if (service == null || !service.StartsWith(ServicePrefix + ":", StringComparison.Ordinal)) continue; - var acctMatch = Regex.Match(entry, @"""acct""=""(.+?)"""); - if (!acctMatch.Success) continue; + var account = ReadAttribute(entry, "acct"); + if (account == null) continue; - results.Add((svcMatch.Groups[1].Value, acctMatch.Groups[1].Value)); + results.Add((service[(ServicePrefix.Length + 1)..], account)); } return results; } - private static string? ParseAccount(string output) + /// The command's arguments, followed by the keychain file when there is one. + private string[] Command(params string[] args) => + _keychainPath == null ? args : [.. args, _keychainPath]; + + /// + /// Joins a command into one line for security -i, or returns null when it can't be + /// sent intact. Each argument is double-quoted with \ before any backslash or quote, + /// which is how security splits an interactive line. A newline would end the + /// command early and a NUL would cut the argument short. + /// + private static string? InteractiveLine(IEnumerable args) { - var match = Regex.Match(output, @"""acct""=""(.+?)"""); - return match.Success ? match.Groups[1].Value : null; + var line = new StringBuilder(); + foreach (var arg in args) + { + if (arg.Contains('\n') || arg.Contains('\0')) return null; + + if (line.Length > 0) line.Append(' '); + line.Append('"').Append(arg.Replace("\\", "\\\\").Replace("\"", "\\\"")).Append('"'); + } + + var text = line.ToString(); + return Encoding.UTF8.GetByteCount(text) <= MaxInteractiveLineBytes ? text : null; + } + + private static string? ReadAttribute(string output, string name) + { + var match = Regex.Match(output, $"^\\s*\"{name}\"=(.*)$", RegexOptions.Multiline); + return match.Success ? DecodePrintedValue(match.Groups[1].Value) : null; + } + + /// + /// Decodes a value as security prints it: "text" when every byte is printable + /// ASCII other than a backslash, otherwise 0x and the bytes in hex, then an escaped + /// copy that is ignored here. An empty value prints as nothing. Returns null for anything + /// else, such as <NULL> for a missing attribute. + /// + private static string? DecodePrintedValue(string printed) + { + if (printed.Length == 0) return ""; + + if (printed.StartsWith("0x", StringComparison.Ordinal)) + { + var end = printed.IndexOf(' '); + var hex = end < 0 ? printed[2..] : printed[2..end]; + try { return Encoding.UTF8.GetString(Convert.FromHexString(hex)); } + catch (FormatException) { return null; } + } + + if (printed.Length >= 2 && printed[0] == '"' && printed[^1] == '"') + return printed[1..^1]; + + return null; } - private static (int ExitCode, string Output) RunSecurity(params string[] args) + private (int ExitCode, string StdOut, string StdErr) RunSecurity(string[] args, string? stdin = null) { var psi = new ProcessStartInfo { - FileName = "/usr/bin/security", + FileName = _securityPath, + RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, + StandardInputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), UseShellExecute = false, CreateNoWindow = true }; @@ -101,16 +186,21 @@ private static (int ExitCode, string Output) RunSecurity(params string[] args) psi.ArgumentList.Add(arg); using var process = Process.Start(psi); - if (process == null) return (-1, string.Empty); + if (process == null) return (-1, string.Empty, string.Empty); // Read both streams concurrently. Doing stderr synchronously while stdout is // async can deadlock if stderr fills its pipe buffer before the process exits // (reproduces on `security dump-keychain` with large keychains). var stdoutTask = process.StandardOutput.ReadToEndAsync(); var stderrTask = process.StandardError.ReadToEndAsync(); + + if (stdin != null) + process.StandardInput.Write(stdin + "\n"); + process.StandardInput.Close(); + process.WaitForExit(); Task.WaitAll(stdoutTask, stderrTask); - return (process.ExitCode, stdoutTask.Result + stderrTask.Result); + return (process.ExitCode, stdoutTask.Result, stderrTask.Result); } } diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs index 3495640b..b338b81b 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs @@ -93,7 +93,7 @@ private static bool HasNotInPattern(PlanNode spoolNode, PlanStatement stmt) { // Check statement text for NOT IN if (string.IsNullOrEmpty(stmt.StatementText) || - !Regex.IsMatch(stmt.StatementText, @"\bNOT\s+IN\b", RegexOptions.IgnoreCase)) + !Regex.IsMatch(MaskCommentsAndLiterals(stmt.StatementText), @"\bNOT\s+IN\b", RegexOptions.IgnoreCase)) // #579 return false; // Walk up the tree checking ancestors and their children @@ -282,13 +282,18 @@ private static HashSet CollectBareOuterReferences(PlanNode node) if (ConvertImplicitWrapsColumn(predicate, identity)) return "Implicit conversion (CONVERT_IMPLICIT)"; + // Both loops below ask which side of its comparison each function call is on. A + // predicate can hold thousands of calls, so the comparisons are found once and each + // side is read once. + var comparisons = new PredicateComparisons(predicate, identity); + // ISNULL / COALESCE wrapping column — on the column side only. ISNULL(@p, 0) on the // parameter side is a runtime constant and seeks fine; flagging it contradicted this // warning's own "wrapping a column" message. col = ISNULL(@p, col) is still caught, // because the column sits inside the function, on its side of the comparison. foreach (Match isnullMatch in IsnullCoalesceRegex.Matches(predicate)) { - if (IsFunctionOnColumnSide(predicate, isnullMatch, identity)) + if (comparisons.IsFunctionOnColumnSide(isnullMatch.Index)) return "ISNULL/COALESCE wrapping column"; } @@ -301,7 +306,7 @@ private static HashSet CollectBareOuterReferences(PlanNode node) foreach (Match funcMatch in FunctionInPredicateRegex.Matches(predicate)) { var funcName = funcMatch.Groups[1].Value.ToUpperInvariant(); - if (funcName != "CONVERT_IMPLICIT" && IsFunctionOnColumnSide(predicate, funcMatch, identity)) + if (funcName != "CONVERT_IMPLICIT" && comparisons.IsFunctionOnColumnSide(funcMatch.Index)) return $"Function call ({funcName}) on column"; } @@ -327,8 +332,9 @@ private static HashSet CollectBareOuterReferences(PlanNode node) /// /// So the question is not whether a conversion is present but what is inside it, which is /// why this reads the CONVERT_IMPLICIT argument list rather than splitting on the comparison - /// operator the way does. The first argument is the target - /// type and carries no brackets; a column reference in the remainder is the conversion input. + /// operator the way does. The first + /// argument is the target type and carries no brackets; a column reference in the remainder is + /// the conversion input. /// /// Internal so the column-vs-variable line can be tested against raw predicate strings in /// showplan shape. covers the unaliased table-variable case — a bare @@ -339,15 +345,29 @@ private static HashSet CollectBareOuterReferences(PlanNode node) ///
internal static bool ConvertImplicitWrapsColumn(string predicate, ScanIdentity? identity = null) { + // Where the argument list of the last conversion read ends. + var readUpTo = 0; + foreach (Match match in ConvertImplicitRegex.Matches(predicate)) { + // A conversion that starts inside the arguments of one already read is skipped. Those + // arguments were checked for a column as a whole, and a conversion written inside a + // string literal there is text, not a conversion. Reading each nested list again took + // time in proportion to the square of the predicate's length when conversions were + // nested thousands deep. + if (match.Index < readUpTo) + continue; + // The regex ends at the opening paren, so its last character is where the args start. - var arguments = ExtractBalancedArguments(predicate, match.Index + match.Length - 1); + var openParenIndex = match.Index + match.Length - 1; + var arguments = ExtractBalancedArguments(predicate, openParenIndex); // Unparseable means we cannot tell what is being converted. Assume the worst, matching // IsFunctionOnColumnSide, rather than silently dropping a real conversion. if (arguments == null || IsColumnReference(arguments, identity)) return true; + + readUpTo = openParenIndex + arguments.Length + 1; } return false; @@ -505,78 +525,120 @@ private static string StripBrackets(string bracketPart) => } /// - /// Checks whether a function call in a predicate is on the column side of the comparison. - /// Predicate ScalarStrings look like: [db].[schema].[table].[col]>dateadd(day,(0),[@var]) - /// If the function is only on the parameter/literal side, it's still SARGable. - /// - /// Only the function's own comparison is read (#556). A compound predicate is - /// several comparisons joined by AND/OR, and the function belongs to exactly one of them. - /// Splitting the whole predicate at its FIRST operator instead put every later comparison, - /// column and all, on the function's side: in [t].[A]=[@1] AND [t].[B]=CONVERT(tinyint,[@2],0) - /// the CONVERT looked like it shared a side with [t].[B], and so did the dateadd in the - /// everyday range [t].[d]>=dateadd(day,(-7),getdate()) AND [t].[d]<getdate(). - /// - /// is passed straight to to - /// cover the unaliased table-variable case, e.g. abs([X])=(1) (#561), and to tell that - /// scan's own bare column apart from another unaliased table variable's outer reference in the - /// same shape, e.g. abs([A]) where A is an outer reference and only X is this scan's own - /// column in [X]=abs([A]) (#564). + /// The comparisons in one predicate, for to ask which + /// side of its comparison each function call is on. The AND/OR operators are found once, and + /// each side of a comparison is read once, however many calls it holds. Finding and reading + /// them again for every call took time in proportion to the square of the predicate's length. /// - private static bool IsFunctionOnColumnSide(string predicate, Match funcMatch, ScanIdentity? identity = null) + private sealed class PredicateComparisons(string predicate, ScanIdentity? identity) { - var comparison = ComparisonContaining(predicate, funcMatch.Index, out var offset); - - var compMatch = ComparisonOperatorRegex.Match(comparison); - if (!compMatch.Success) - return true; // No comparison found — can't determine side, assume worst case - - var compPos = compMatch.Index; - var funcPos = funcMatch.Index - offset; + /// Where each AND/OR between two comparisons starts and ends, in order. + private List<(int Start, int End)>? _logicalOperators; + + /// Each comparison read so far, by where it starts in the predicate. + private readonly Dictionary _comparisons = new(); + + /// Whether a column is on one side of a comparison, by where the comparison + /// starts and whether the side is the one before its operator. + private readonly Dictionary<(int Start, bool BeforeOperator), bool> _sideHasColumn = new(); + + /// + /// Checks whether the function call at is on the column + /// side of its comparison. Predicate ScalarStrings look like: + /// [db].[schema].[table].[col]>dateadd(day,(0),[@var]) + /// If the function is only on the parameter/literal side, it's still SARGable. + /// + /// Only the function's own comparison is read (#556). A compound predicate is + /// several comparisons joined by AND/OR, and the function belongs to exactly one of them. + /// Splitting the whole predicate at its FIRST operator instead put every later comparison, + /// column and all, on the function's side: in [t].[A]=[@1] AND [t].[B]=CONVERT(tinyint,[@2],0) + /// the CONVERT looked like it shared a side with [t].[B], and so did the dateadd in the + /// everyday range [t].[d]>=dateadd(day,(-7),getdate()) AND [t].[d]<getdate(). + /// + /// The scan identity is passed straight to to cover + /// the unaliased table-variable case, e.g. abs([X])=(1) (#561), and to tell that + /// scan's own bare column apart from another unaliased table variable's outer reference in + /// the same shape, e.g. abs([A]) where A is an outer reference and only X is this + /// scan's own column in [X]=abs([A]) (#564). + /// + public bool IsFunctionOnColumnSide(int functionIndex) + { + var (start, end) = ComparisonAround(functionIndex); + if (!_comparisons.TryGetValue(start, out var comparison)) + { + var text = predicate[start..end]; + comparison = (text, ComparisonOperatorRegex.Match(text)); + _comparisons.Add(start, comparison); + } - // The side of this comparison the function is on, and whether a column shares it - string side = funcPos < compPos - ? comparison[..compPos] - : comparison[(compPos + compMatch.Length)..]; + var compMatch = comparison.Operator; + if (!compMatch.Success) + return true; // No comparison found — can't determine side, assume worst case - // Same column-vs-variable distinction ConvertImplicitWrapsColumn needs, so it shares the - // one helper rather than keeping a second copy of the logic in sync by hand. - return IsColumnReference(side, identity); - } + // The side of this comparison the function is on, and whether a column shares it + var beforeOperator = functionIndex - start < compMatch.Index; + if (!_sideHasColumn.TryGetValue((start, beforeOperator), out var hasColumn)) + { + var side = beforeOperator + ? comparison.Text[..compMatch.Index] + : comparison.Text[(compMatch.Index + compMatch.Length)..]; + + // Same column-vs-variable distinction ConvertImplicitWrapsColumn needs, so it + // shares the one helper rather than keeping a second copy of the logic in sync. + hasColumn = IsColumnReference(side, identity); + _sideHasColumn.Add((start, beforeOperator), hasColumn); + } - /// - /// The single comparison around : the text between the nearest - /// AND/OR before it and the nearest after it. is where that text - /// starts in , so positions can be translated into it. - /// - /// Operators are split on at every depth, not just the top level: a parenthesized group - /// like [t].[A]=(1) AND ([t].[B]=f([@p]) OR [t].[C]=(3)) has to come apart into its - /// three comparisons, or the group would be read as one. The leftover grouping parentheses - /// cannot move a comparison operator or add a column, so they are harmless. No function in a - /// ScalarString takes AND/OR inside its arguments; CASE does, and it is caught earlier. - /// - private static string ComparisonContaining(string predicate, int position, out int offset) - { - var start = 0; - var end = predicate.Length; + return hasColumn; + } - foreach (Match match in LogicalOperatorRegex.Matches(predicate)) + /// + /// The single comparison around : the text between the nearest + /// AND/OR before it and the nearest after it, as where it starts and ends in the predicate. + /// + /// Operators are split on at every depth, not just the top level: a parenthesized + /// group like [t].[A]=(1) AND ([t].[B]=f([@p]) OR [t].[C]=(3)) has to come apart into + /// its three comparisons, or the group would be read as one. The leftover grouping + /// parentheses cannot move a comparison operator or add a column, so they are harmless. No + /// function in a ScalarString takes AND/OR inside its arguments; CASE does, and it is + /// caught earlier. + /// + private (int Start, int End) ComparisonAround(int position) { - if (!match.Groups[1].Success) - continue; // a string literal or bracketed name, skipped whole - - if (match.Index + match.Length <= position) + _logicalOperators ??= FindLogicalOperators(predicate); + + // The first operator that ends after position closes the comparison, and the one + // before it opens it. The operators are in order and never overlap, so their ends + // are in order too. + var low = 0; + var high = _logicalOperators.Count; + while (low < high) { - start = match.Index + match.Length; + var middle = (low + high) / 2; + if (_logicalOperators[middle].End <= position) + low = middle + 1; + else + high = middle; } - else + + var start = low > 0 ? _logicalOperators[low - 1].End : 0; + var end = low < _logicalOperators.Count ? _logicalOperators[low].Start : predicate.Length; + return (start, end); + } + + private static List<(int Start, int End)> FindLogicalOperators(string predicate) + { + var operators = new List<(int Start, int End)>(); + foreach (Match match in LogicalOperatorRegex.Matches(predicate)) { - end = match.Index; - break; + if (!match.Groups[1].Success) + continue; // a string literal or bracketed name, skipped whole + + operators.Add((match.Index, match.Index + match.Length)); } - } - offset = start; - return predicate[start..end]; + return operators; + } } /// diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs index cb141850..b4076280 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs @@ -8,15 +8,15 @@ namespace PlanViewer.Core.Services; public static partial class PlanAnalyzer { - /* Both passes below match on WarningType alone, and a type name is not unique to us: + /* MarkLegacyWarnings matches on WarningType alone, and a type name is not unique to us: "Implicit Conversion" is rule 29's legacy-listed type AND what the parser stamps on the engine's own PlanAffectingConvert element (Source = SqlServer). Matching by name only therefore branded the ENGINE's record "[SQL Server] [legacy]" — a badge that exists to - flag our un-migrated rules on a warning that is not ours at all — and TryOverrideSeverity - routed a user's rule-number override onto engine warnings the rule never produced (the - Contains matching makes it worse: every engine Spill variant lands on rule 7, "Memory - Grant" on rule 9). Legacy status and rule severity are facts about OUR rules, so anything - the engine said is skipped by both. */ + flag our un-migrated rules on a warning that is not ours at all. TryOverrideSeverity + matched by name too until #575, and routed a user's rule-number override onto engine + warnings the rule never produced (its Contains matching sent every engine Spill variant + to rule 7 and "Memory Grant" to rule 9). Legacy status and rule severity are facts about + OUR rules, so anything the engine said is skipped by both. */ private static void MarkLegacyWarnings(PlanStatement stmt) { foreach (var w in stmt.PlanWarnings) @@ -72,21 +72,14 @@ private static void TryOverrideSeverity(PlanWarning warning, AnalyzerConfig cfg) if (warning.Source == PlanWarningSource.SqlServer) return; - // Find the rule number for this warning type (partial match for flexibility) - int? ruleNumber = null; - foreach (var (rule, type) in RuleWarningTypes) - { - if (warning.WarningType.Contains(type, StringComparison.OrdinalIgnoreCase) || - type.Contains(warning.WarningType, StringComparison.OrdinalIgnoreCase)) - { - ruleNumber = rule; - break; - } - } - - if (ruleNumber == null) return; + /* #575: the rule that emits a finding stamps its number on it. Overrides used to find the + rule by matching WarningType against a rule-to-name table, and that table had no entry + for rules 34-37 and 39, for two of rule 30's three finding types, or for rule 10's RID + Lookup, so an override for any of them was silently ignored. */ + if (warning.RuleNumber is not int ruleNumber) + return; - var overrideSeverity = cfg.GetSeverityOverride(ruleNumber.Value); + var overrideSeverity = cfg.GetSeverityOverride(ruleNumber); if (overrideSeverity == null) return; if (Enum.TryParse(overrideSeverity, ignoreCase: true, out var severity)) @@ -281,6 +274,91 @@ private static string FormatNodeRef(PlanNode node) return $"{node.PhysicalOp} (Node {node.NodeId})"; } + /* #579: the rules that look for a hint or a keyword in the query text (MAXDOP 1, MAXDOP 2, + RECOMPILE, OPTIMIZE FOR UNKNOWN, NOT IN, a cursor declaration, a row goal's cause) matched the raw + StatementText, so the words inside a string literal or a comment counted as code. This + blanks string-literal contents and whole comments with spaces, keeping every other character + where it was, so a match in the result is a match in the code. The scan follows + ParameterSubstitution's: '...' with doubled-quote escapes, -- to the end of the line, and + block comments, which nest in T-SQL. Delimited identifiers ("..." and [...]) are stepped + over unchanged, so a quote or a dash inside one does not start a string or a comment. */ + internal static string MaskCommentsAndLiterals(string? text) + { + if (string.IsNullOrEmpty(text)) + return ""; + + var chars = text.ToCharArray(); + var i = 0; + while (i < chars.Length) + { + var c = chars[i]; + if (c == '\'' || c == '"' || c == '[') + { + var close = c == '[' ? ']' : c; + var end = i + 1; + while (end < chars.Length) + { + if (chars[end] == close) + { + if (end + 1 < chars.Length && chars[end + 1] == close) + { + end += 2; + continue; + } + break; + } + end++; + } + if (c == '\'') + { + for (var k = i + 1; k < end && k < chars.Length; k++) + chars[k] = ' '; + } + i = end + 1; + continue; + } + + if (c == '-' && i + 1 < chars.Length && chars[i + 1] == '-') + { + while (i < chars.Length && chars[i] != '\n' && chars[i] != '\r') + chars[i++] = ' '; + continue; + } + + if (c == '/' && i + 1 < chars.Length && chars[i + 1] == '*') + { + var depth = 0; + while (i < chars.Length) + { + if (chars[i] == '/' && i + 1 < chars.Length && chars[i + 1] == '*') + { + depth++; + chars[i++] = ' '; + chars[i++] = ' '; + continue; + } + if (chars[i] == '*' && i + 1 < chars.Length && chars[i + 1] == '/') + { + depth--; + chars[i++] = ' '; + chars[i++] = ' '; + if (depth == 0) + break; + continue; + } + if (chars[i] != '\n' && chars[i] != '\r') + chars[i] = ' '; + i++; + } + continue; + } + + i++; + } + + return new string(chars); + } + /// /// Identifies the specific cause of a row goal from the statement text. /// Returns a specific cause when detectable, or a generic list as fallback. @@ -290,7 +368,7 @@ private static string IdentifyRowGoalCause(string stmtText) if (string.IsNullOrEmpty(stmtText)) return "TOP, EXISTS, IN, or FAST hint"; - var text = stmtText.ToUpperInvariant(); + var text = MaskCommentsAndLiterals(stmtText).ToUpperInvariant(); var causes = new List(4); if (Regex.IsMatch(text, @"\bTOP\b")) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs index dd35443b..8f19a844 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs @@ -99,6 +99,7 @@ private static void Rule01_FilterOperator(PlanNode node, PlanStatement stmt, Ana node.Warnings.Add(new PlanWarning { + RuleNumber = 1, WarningType = "Filter Operator", Message = message, Severity = PlanWarningSeverity.Warning @@ -120,6 +121,7 @@ private static void Rule02_EagerIndexSpool(PlanNode node, PlanStatement stmt, An node.Warnings.Add(new PlanWarning { + RuleNumber = 2, WarningType = "Eager Index Spool", Message = message, Severity = PlanWarningSeverity.Critical @@ -135,6 +137,7 @@ private static void Rule04_UdfTiming(PlanNode node, PlanStatement stmt, Analyzer { node.Warnings.Add(new PlanWarning { + RuleNumber = 4, WarningType = "UDF Execution", Message = $"Scalar UDF executing on this operator ({node.UdfElapsedTimeMs:N0}ms elapsed, {node.UdfCpuTimeMs:N0}ms CPU). Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.", Severity = node.UdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning @@ -151,7 +154,11 @@ private static void Rule05_RowEstimateMismatch(PlanNode node, PlanStatement stmt // - A parent join may have chosen the wrong strategy // - Root nodes with no parent to harm are skipped // - Nodes whose only parents are Parallelism/Top/Sort (no spill) are skipped + /* #577: an operator that never executed returned zero rows because it never ran, so its + zero is no evidence that the estimate was wrong. Rules 11, 12 and 29 skip such + operators the same way. */ if (!cfg.IsRuleDisabled(5) && node.HasActualStats && node.EstimateRows > 0 + && node.ActualExecutions > 0 && !node.Lookup) // Key lookups are point lookups (1 row per execution) — per-execution estimate is misleading { if (node.ActualRows == 0) @@ -164,6 +171,7 @@ private static void Rule05_RowEstimateMismatch(PlanNode node, PlanStatement stmt { node.Warnings.Add(new PlanWarning { + RuleNumber = 5, WarningType = "Row Estimate Mismatch", Message = $"Estimated {node.EstimateRows:N0} rows but actual 0 rows returned. SQL Server allocated resources for rows that never materialized.", Severity = PlanWarningSeverity.Warning @@ -172,10 +180,13 @@ private static void Rule05_RowEstimateMismatch(PlanNode node, PlanStatement stmt } else { - // Compare per-execution actuals to estimates (SQL Server estimates are per-execution) - var executions = node.ActualExecutions > 0 ? node.ActualExecutions : 1; - var actualPerExec = (double)node.ActualRows / executions; - var ratio = actualPerExec / node.EstimateRows; + // #594: compare like with like. EstimateRows is per execution; ActualExecutions + // is a real per-execution count only on the inner side of a Nested Loops join — + // everywhere else (including a non-inner node in a parallel zone, where it is + // thread-summed) RowEstimateHelper leaves the estimate at one execution instead + // of inflating it by DOP. + var isInnerSide = RowEstimateHelper.IsInnerSideOfNestedLoops(node); + var ratio = RowEstimateHelper.GetRowAccuracyRatio(node); if (ratio >= 10.0 || ratio <= 0.1) { var harm = AssessEstimateHarm(node, ratio); @@ -183,11 +194,12 @@ private static void Rule05_RowEstimateMismatch(PlanNode node, PlanStatement stmt { var direction = ratio >= 10.0 ? "underestimated" : "overestimated"; var factor = ratio >= 10.0 ? ratio : 1.0 / ratio; - var actualDisplay = executions > 1 - ? $"Actual {node.ActualRows:N0} ({actualPerExec:N0} rows x {executions:N0} executions)" + var actualDisplay = isInnerSide && node.ActualExecutions > 1 + ? $"Actual {node.ActualRows:N0} ({(double)node.ActualRows / node.ActualExecutions:N0} rows x {node.ActualExecutions:N0} executions)" : $"Actual {node.ActualRows:N0}"; node.Warnings.Add(new PlanWarning { + RuleNumber = 5, WarningType = "Row Estimate Mismatch", Message = $"Estimated {node.EstimateRows:N0} vs {actualDisplay} — {factor:F0}x {direction}. {harm}", Severity = factor >= 100 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning @@ -204,16 +216,22 @@ private static void Rule06_ScalarUdfReference(PlanNode node, PlanStatement stmt, // Rule 6: Scalar UDF references (works on estimated plans too) // Suppress when Serial Plan warning is already firing for a UDF-related reason — // the Serial Plan warning already explains the issue, this would be redundant. - var serialPlanCoversUdf = stmt.NonParallelPlanReason is - "TSQLUserDefinedFunctionsNotParallelizable" - or "CLRUserDefinedFunctionRequiresDataAccess" - or "CouldNotGenerateValidParallelPlan"; + /* #576: "already firing" has to be checked, not assumed from the reason. Rule 3 can be + disabled, and it skips statements that cost under 1, TRIVIAL plans and 0 ms runs, so + the UDF warning used to vanish with nothing in its place. Statement rules run before + node rules, so rule 3's finding is on the statement by now if it fired. */ + var serialPlanCoversUdf = + (stmt.NonParallelPlanReason is "TSQLUserDefinedFunctionsNotParallelizable" + or "CLRUserDefinedFunctionRequiresDataAccess" + or "CouldNotGenerateValidParallelPlan") + && stmt.PlanWarnings.Any(w => w.WarningType == "Serial Plan"); if (!cfg.IsRuleDisabled(6) && !serialPlanCoversUdf) foreach (var udf in node.ScalarUdfs) { var type = udf.IsClrFunction ? "CLR" : "T-SQL"; node.Warnings.Add(new PlanWarning { + RuleNumber = 6, WarningType = "Scalar UDF", Message = $"Scalar {type} UDF: {udf.FunctionName}. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.", Severity = PlanWarningSeverity.Warning @@ -317,6 +335,7 @@ private static void Rule08_ParallelThreadSkew(PlanNode node, PlanStatement stmt, node.Warnings.Add(new PlanWarning { + RuleNumber = 8, WarningType = "Parallel Skew", Message = message, Severity = severity @@ -339,6 +358,7 @@ private static void Rule10_LookupResidual(PlanNode node, PlanStatement stmt, Ana node.Warnings.Add(new PlanWarning { + RuleNumber = 10, WarningType = "RID Lookup", Message = message, Severity = PlanWarningSeverity.Warning @@ -365,6 +385,7 @@ private static void Rule10_LookupResidual(PlanNode node, PlanStatement stmt, Ana node.Warnings.Add(new PlanWarning { + RuleNumber = 10, WarningType = "Key Lookup", Message = lookupMsg, Severity = PlanWarningSeverity.Critical @@ -405,6 +426,7 @@ _ when nonSargableReason.StartsWith("Function call") => node.Warnings.Add(new PlanWarning { + RuleNumber = 12, WarningType = "Non-SARGable Predicate", Message = $"{nonSargableAdvice}\nPredicate: {Truncate(node.Predicate!, 200)}", Severity = PlanWarningSeverity.Warning @@ -452,6 +474,7 @@ private static void Rule11_ScanResidual(PlanNode node, PlanStatement stmt, Analy node.Warnings.Add(new PlanWarning { + RuleNumber = 11, WarningType = "Scan With Predicate", Message = message, Severity = severity @@ -480,6 +503,7 @@ private static void Rule32_CardinalityMisestimateScan(PlanNode node, PlanStateme { node.Warnings.Add(new PlanWarning { + RuleNumber = 32, WarningType = "Scan Cardinality Misestimate", Message = $"Estimated {node.EstimateRows:N0} rows but only {node.ActualRows:N0} returned ({selectivity * 100:N3}% of {node.ActualRowsRead:N0} rows read). " + $"The {overestimateRatio:N0}x overestimate likely caused the optimizer to choose a scan instead of a seek. " + @@ -494,21 +518,25 @@ private static void Rule32_CardinalityMisestimateScan(PlanNode node, PlanStateme private static void Rule33_CeGuessDetection(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) { // Rule 33: Estimated plan CE guess detection — scans with telltale default selectivity - // When the optimizer uses a local variable or can't sniff, it falls back to density-based - // guesses: 30% (equality), 10% (inequality), 9% (LIKE/between), ~16.43% (sqrt(30%)), - // 1% (multi-inequality). On large tables, these guesses can hide the need for an index. + // When the optimizer has no statistics to use (a local variable it can't sniff, a column with + // no statistics, an expression), it falls back on fixed guesses: 30% for an inequality, 9% + // for BETWEEN or LIKE, ~16.4% for two inequalities, 10% for comparing two columns, and an + // equality guess that grows with the table. DetectCeGuess has the measured details and + // which estimator (CE 70 or 120+) each one belongs to. On large tables, these guesses can + // hide the need for an index. if (!cfg.IsRuleDisabled(33) && !node.HasActualStats && IsRowstoreScan(node) - && node.TableCardinality >= 100_000 && node.EstimateRows > 0 + && node.TableCardinality >= CeGuessMinTableRows && node.EstimateRows > 0 && !string.IsNullOrEmpty(node.Predicate)) { var impact = BuildScanImpactDetails(node, stmt); if (impact.CostPct >= 50) { - var guessDesc = DetectCeGuess(node.EstimateRows, node.TableCardinality); + var guessDesc = DetectCeGuess(node.EstimateRows, node.TableCardinality, stmt.CardinalityEstimationModelVersion); if (guessDesc != null) { node.Warnings.Add(new PlanWarning { + RuleNumber = 33, WarningType = "Estimated Plan CE Guess", Message = $"Estimated {node.EstimateRows:N0} rows from {node.TableCardinality:N0} row table — {guessDesc}. " + $"The optimizer may be using a default guess instead of accurate statistics. " + @@ -554,6 +582,7 @@ private static void Rule34_BareScanNarrowOutput(PlanNode node, PlanStatement stm node.Warnings.Add(new PlanWarning { + RuleNumber = 34, WarningType = "Bare Scan", Message = $"{scanKind} reads the full table with no predicate, outputting {colCount} column(s): {Truncate(node.OutputColumns, 200)}. {indexAdvice} For analytical workloads, a columnstore index may be a better fit.", Severity = PlanWarningSeverity.Warning @@ -566,6 +595,7 @@ private static void Rule34_BareScanNarrowOutput(PlanNode node, PlanStatement stm // count. Suggest it for analytical / aggregate-style workloads. node.Warnings.Add(new PlanWarning { + RuleNumber = 34, WarningType = "Bare Scan", Message = $"{scanKind} reads the full table with no predicate, outputting {colCount} columns. A nonclustered rowstore index isn't a great fit for wide outputs, but if this is an analytical or aggregate-style query, a columnstore index (CCI or NCCI) can scan the same data far more cheaply — column count doesn't penalize columnstore the way it does rowstore indexes.", Severity = PlanWarningSeverity.Warning @@ -592,6 +622,7 @@ private static void Rule13_MismatchedDataTypes(PlanNode node, PlanStatement stmt node.Warnings.Add(new PlanWarning { + RuleNumber = 13, WarningType = "Data Type Mismatch", Message = reason, Severity = PlanWarningSeverity.Warning @@ -626,6 +657,7 @@ private static void Rule14_LazyTableSpoolRebind(PlanNode node, PlanStatement stm node.Warnings.Add(new PlanWarning { + RuleNumber = 14, WarningType = "Lazy Spool Ineffective", Message = $"Lazy spool has low cache hit ratio ({source}): {rebinds:N0} rebinds (cache misses), {rewinds:N0} rewinds (cache hits) — {ratio}. The spool is caching results but rarely reusing them, adding overhead for no benefit.", Severity = severity @@ -655,6 +687,7 @@ value from another input's row makes the OR a join OR. */ { node.Warnings.Add(new PlanWarning { + RuleNumber = 15, WarningType = "Join OR Clause", Message = $"OR in a join predicate. SQL Server rewrote the OR as {constantScanBranches.Count} separate lookups, each evaluated independently — this multiplies the work on the inner side. Rewrite as separate queries joined with UNION ALL. For example, change \"FROM a JOIN b ON a.x = b.x OR a.y = b.y\" to \"FROM a JOIN b ON a.x = b.x UNION ALL FROM a JOIN b ON a.y = b.y\".", Severity = PlanWarningSeverity.Warning @@ -684,15 +717,20 @@ private static void Rule16_NestedLoopsHighExec(PlanNode node, PlanStatement stmt // Core fact details.Add($"Nested Loops inner side executed {innerChild.ActualExecutions:N0} times (DOP {dop})."); - // Outer side estimate mismatch — explains WHY the optimizer chose NL + // Outer side estimate mismatch — explains WHY the optimizer chose NL. + // #594: outerChild is this join's OUTER input, but if this Nested Loops is itself + // nested inside an ancestor join's inner side, outerChild inherits that — walk the + // whole ancestor chain (RowEstimateHelper) rather than assuming one execution. if (outerChild.HasActualStats && outerChild.EstimateRows > 0) { - var outerExecs = outerChild.ActualExecutions > 0 ? outerChild.ActualExecutions : 1; - var outerActualPerExec = (double)outerChild.ActualRows / outerExecs; - var outerRatio = outerActualPerExec / outerChild.EstimateRows; + var outerIsInnerSide = RowEstimateHelper.IsInnerSideOfNestedLoops(outerChild); + var outerRatio = RowEstimateHelper.GetRowAccuracyRatio(outerChild); if (outerRatio >= 10.0) { - details.Add($"Outer side: estimated {outerChild.EstimateRows:N0} rows, actual {outerActualPerExec:N0} ({outerRatio:F0}x underestimate). The optimizer chose Nested Loops expecting far fewer iterations."); + var outerActualDisplay = outerIsInnerSide && outerChild.ActualExecutions > 0 + ? (double)outerChild.ActualRows / outerChild.ActualExecutions + : outerChild.ActualRows; + details.Add($"Outer side: estimated {outerChild.EstimateRows:N0} rows, actual {outerActualDisplay:N0} ({outerRatio:F0}x underestimate). The optimizer chose Nested Loops expecting far fewer iterations."); } } @@ -724,6 +762,7 @@ private static void Rule16_NestedLoopsHighExec(PlanNode node, PlanStatement stmt node.Warnings.Add(new PlanWarning { + RuleNumber = 16, WarningType = "Nested Loops High Executions", Message = string.Join(" ", details), Severity = innerChild.ActualExecutions > 1000000 @@ -747,6 +786,7 @@ private static void Rule17_ManyToManyMerge(PlanNode node, PlanStatement stmt, An { node.Warnings.Add(new PlanWarning { + RuleNumber = 17, WarningType = "Many-to-Many Merge Join", Message = node.HasActualStats ? $"Many-to-many Merge Join — SQL Server created a worktable in TempDB ({node.ActualLogicalReads:N0} logical reads) because both sides have duplicate values in the join columns." @@ -769,6 +809,7 @@ private static void Rule22_TableVariables(PlanNode node, PlanStatement stmt, Ana node.Warnings.Add(new PlanWarning { + RuleNumber = 22, WarningType = "Table Variable", Message = isModificationOp ? "Modifying a table variable forces the entire plan to run single-threaded. Replace with a #temp table to allow parallel execution." @@ -782,11 +823,18 @@ private static void Rule22_TableVariables(PlanNode node, PlanStatement stmt, Ana private static void Rule23_TableValuedFunctions(PlanNode node, PlanStatement stmt, AnalyzerConfig cfg) { // Rule 23: Table-valued functions - if (!cfg.IsRuleDisabled(23) && node.LogicalOp == "Table-valued function") + /* A function the engine supplies runs as the same operator: STRING_SPLIT, OPENJSON, + GENERATE_SERIES, and every DMV and DMF (sys.dm_exec_requests is SYSREQUESTS, + sys.dm_db_index_physical_stats is INDEXANALYSIS). Its Object names no database and no + schema, and a function a user wrote always has both. The advice below is about code + the user can rewrite, so the engine's own functions are skipped. */ + var isEngineFunction = string.IsNullOrEmpty(node.DatabaseName) && string.IsNullOrEmpty(node.SchemaName); + if (!cfg.IsRuleDisabled(23) && node.LogicalOp == "Table-valued function" && !isEngineFunction) { var funcName = node.ObjectName ?? node.PhysicalOp; node.Warnings.Add(new PlanWarning { + RuleNumber = 23, WarningType = "Table-Valued Function", Message = $"Table-valued function: {funcName}. Multi-statement TVFs have no statistics — SQL Server guesses 1 row (pre-2017) or 100 rows (2017+) regardless of actual size. Rewrite as an inline table-valued function if possible, or dump the function results into a #temp table and join to that instead.", Severity = PlanWarningSeverity.Warning @@ -827,6 +875,7 @@ private static void Rule24_TopAboveScan(PlanNode node, PlanStatement stmt, Analy : ""; node.Warnings.Add(new PlanWarning { + RuleNumber = 24, WarningType = "Top Above Scan", Message = $"{topLabel} reads from {FormatNodeRef(scanCandidate)}.{innerNote}{predInfo} An index on the ORDER BY columns could eliminate the scan and sort entirely.", Severity = onInner ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning @@ -857,9 +906,11 @@ private static void Rule26_RowGoal(PlanNode node, PlanStatement stmt, AnalyzerCo var rowGoalWorked = false; if (node.HasActualStats) { - var executions = node.ActualExecutions > 0 ? node.ActualExecutions : 1; - var actualPerExec = (double)node.ActualRows / executions; - rowGoalWorked = actualPerExec <= node.EstimateRows; + // #594: compare like with like — see RowEstimateHelper. A scan or seek is + // routinely the inner side of a Nested Loops join (a key lookup, for one), + // where ActualExecutions is a real per-execution count; everywhere else it + // must not be treated as one. + rowGoalWorked = RowEstimateHelper.GetRowAccuracyRatio(node) <= 1.0; } if (!rowGoalWorked) @@ -869,6 +920,7 @@ private static void Rule26_RowGoal(PlanNode node, PlanStatement stmt, AnalyzerCo node.Warnings.Add(new PlanWarning { + RuleNumber = 26, WarningType = "Row Goal", Message = $"Row goal active: estimate reduced from {node.EstimateRowsWithoutRowGoal:N0} to {node.EstimateRows:N0} ({reduction:N0}x reduction) due to {cause}. The optimizer chose this plan shape expecting to stop reading early. If the query reads all rows anyway, the plan choice may be suboptimal.", Severity = PlanWarningSeverity.Info @@ -891,6 +943,7 @@ private static void Rule28_RowCountSpool(PlanNode node, PlanStatement stmt, Anal { node.Warnings.Add(new PlanWarning { + RuleNumber = 28, WarningType = "NOT IN with Nullable Column", Message = $"Row Count Spool with {rewinds:N0} rewinds. This pattern occurs when NOT IN is used with a nullable column — SQL Server cannot use an efficient Anti Semi Join because it must check for NULL values on every outer row. Rewrite as NOT EXISTS, or add WHERE column IS NOT NULL to the subquery.", Severity = rewinds > 1_000_000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning @@ -931,7 +984,13 @@ private static void Rule35_ExpensiveOperator(PlanNode node, PlanStatement stmt, // to still surface as top items. Threshold: self-time >= 20% of statement // elapsed. Only emits if no other warning is already on the node to avoid // doubling up. The benefit % is just the self-time share. + // Exchanges (Parallelism) are skipped: their self-time is mostly time spent waiting on the + // operators that feed them and drain them, not work of their own. On a live plan an exchange + // feeding a spilling sort showed 21 s of elapsed time on 2.4 s of CPU per thread, and was + // named as the expensive operator while the sort beside it was the real problem. The text + // report's "Expensive operators" list skips exchanges for the same reason. if (!cfg.IsRuleDisabled(35) && node.HasActualStats && node.Warnings.Count == 0 + && !NodeTimeAttribution.IsExchangeOperator(node) && stmt.QueryTimeStats != null && stmt.QueryTimeStats.ElapsedTimeMs >= Rule35MinStatementElapsedMs) { var selfMs = GetOperatorOwnElapsedMs(node); @@ -940,6 +999,7 @@ private static void Rule35_ExpensiveOperator(PlanNode node, PlanStatement stmt, { node.Warnings.Add(new PlanWarning { + RuleNumber = 35, WarningType = "Expensive Operator", Message = $"{node.PhysicalOp} took {selfMs:N0}ms ({pct:N1}% of statement elapsed) but no specific rule identified a fix. Worth investigating: is the row volume necessary? Are upstream estimates driving this operator harder than it should be?", Severity = pct >= 50 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning, diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs index fab1ca93..f3741b99 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs @@ -45,6 +45,7 @@ this rule cannot see. Where the full text was recovered (#502), ResultMapper swa message for one that says so. */ stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 39, WarningType = "Truncated Query Text", Message = "SQL Server truncated this query's text at 4,000 characters when it wrote the plan, " @@ -135,7 +136,7 @@ private static void Rule03_SerialPlan(PlanStatement stmt, AnalyzerConfig cfg, Se // SQL Server truncates StatementText at ~4,000 characters in plan XML. if (stmt.NonParallelPlanReason == "MaxDOPSetToOne") { - var text = stmt.StatementText ?? ""; + var text = MaskCommentsAndLiterals(stmt.StatementText); // #579 var hasMaxdop1InText = Regex.IsMatch(text, @"MAXDOP\s+1\b", RegexOptions.IgnoreCase); var isTruncated = stmt.IsTextTruncated; @@ -144,6 +145,7 @@ private static void Rule03_SerialPlan(PlanStatement stmt, AnalyzerConfig cfg, Se // User explicitly set MAXDOP 1 in the query — warn stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 3, WarningType = "Serial Plan", Message = "Query running serially: MAXDOP is set to 1 using a query hint.", Severity = PlanWarningSeverity.Warning @@ -154,6 +156,7 @@ private static void Rule03_SerialPlan(PlanStatement stmt, AnalyzerConfig cfg, Se // Query text was truncated — can't tell if MAXDOP 1 is in the query stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 3, WarningType = "Serial Plan", Message = $"Query running serially: {reason}. MAXDOP 1 may be set at the server, database, resource governor, or query level (query text was truncated).", Severity = PlanWarningSeverity.Info @@ -165,6 +168,7 @@ private static void Rule03_SerialPlan(PlanStatement stmt, AnalyzerConfig cfg, Se { stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 3, WarningType = "Serial Plan", Message = $"Query running serially: {reason}.", Severity = isActionable ? PlanWarningSeverity.Warning : PlanWarningSeverity.Info @@ -181,15 +185,19 @@ private static void Rule09_MemoryGrant(PlanStatement stmt, AnalyzerConfig cfg, S { var grant = stmt.MemoryGrant; - // Excessive grant — granted far more than actually used - if (grant.GrantedMemoryKB > 0 && grant.MaxUsedMemoryKB > 0) + // Excessive grant — granted far more than actually used. A grant that used nothing is the + // worst case, so MaxUsedMemory="0" counts, but only when the plan reported it: a plan with + // no MaxUsedMemory attribute (estimated, or no runtime grant info) says nothing about use. + if (grant.GrantedMemoryKB >= 1048576 && grant.HasMaxUsedMemory) { - var wasteRatio = (double)grant.GrantedMemoryKB / grant.MaxUsedMemoryKB; - if (wasteRatio >= 10 && grant.GrantedMemoryKB >= 1048576) + var usedNothing = grant.MaxUsedMemoryKB <= 0; + var wasteRatio = usedNothing ? 0 : (double)grant.GrantedMemoryKB / grant.MaxUsedMemoryKB; + if (usedNothing || wasteRatio >= 10) { var grantMB = grant.GrantedMemoryKB / 1024.0; - var usedMB = grant.MaxUsedMemoryKB / 1024.0; - var message = $"Granted {grantMB:N0} MB but only used {usedMB:N0} MB ({wasteRatio:F0}x overestimate). The unused memory is reserved and unavailable to other queries."; + var message = usedNothing + ? $"Granted {grantMB:N0} MB but the query used none of it. The unused memory is reserved and unavailable to other queries." + : $"Granted {grantMB:N0} MB but only used {grant.MaxUsedMemoryKB / 1024.0:N0} MB ({wasteRatio:F0}x overestimate). The unused memory is reserved and unavailable to other queries."; // Note adaptive joins that chose Nested Loops at runtime — the grant // was sized for a hash join that never happened. @@ -198,6 +206,7 @@ private static void Rule09_MemoryGrant(PlanStatement stmt, AnalyzerConfig cfg, S stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 9, WarningType = "Excessive Memory Grant", Message = message, Severity = PlanWarningSeverity.Warning @@ -210,6 +219,7 @@ private static void Rule09_MemoryGrant(PlanStatement stmt, AnalyzerConfig cfg, S { stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 9, WarningType = "Memory Grant Wait", Message = $"Query waited {grant.GrantWaitTimeMs:N0}ms for a memory grant before it could start running. Other queries were using all available workspace memory.", Severity = grant.GrantWaitTimeMs >= 5000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning @@ -237,6 +247,7 @@ private static void Rule09_MemoryGrant(PlanStatement stmt, AnalyzerConfig cfg, S stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 9, WarningType = "Large Memory Grant", Message = $"Query granted {grantMB:F0} MB of memory.{guidance}", Severity = grantMB >= 4096 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning @@ -253,6 +264,7 @@ private static void Rule18_CompileMemoryExceeded(PlanStatement stmt, AnalyzerCon { stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 18, WarningType = "Compile Memory Exceeded", Message = "Optimization was aborted early because the compile memory limit was exceeded. The plan is likely suboptimal. Simplify the query by breaking it into smaller steps using #temp tables.", Severity = PlanWarningSeverity.Critical @@ -268,6 +280,7 @@ private static void Rule19_HighCompileCpu(PlanStatement stmt, AnalyzerConfig cfg { stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 19, WarningType = "High Compile CPU", Message = $"Query took {stmt.CompileCPUMs:N0}ms of CPU just to compile a plan (before any data was read). Simplify the query by breaking it into smaller steps using #temp tables.", Severity = stmt.CompileCPUMs >= 5000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning @@ -284,6 +297,7 @@ private static void Rule04Stmt_UdfExecution(PlanStatement stmt, AnalyzerConfig c { stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 4, WarningType = "UDF Execution", Message = $"Scalar UDF cost in this statement: {stmt.QueryUdfElapsedTimeMs:N0}ms elapsed, {stmt.QueryUdfCpuTimeMs:N0}ms CPU. Scalar UDFs run once per row and prevent parallelism. Options: rewrite as an inline table-valued function, assign the result to a variable if only one row is needed, dump results to a #temp table and apply the UDF to the final result set, or on SQL Server 2019+ check if the UDF is eligible for automatic scalar UDF inlining.", Severity = stmt.QueryUdfElapsedTimeMs >= 1000 ? PlanWarningSeverity.Critical : PlanWarningSeverity.Warning @@ -306,12 +320,14 @@ private static void Rule20_LocalVariablesNoRecompile(PlanStatement stmt, Analyze if (unsnifffedParams.Count > 0) { - var hasRecompile = stmt.StatementText?.Contains("RECOMPILE", StringComparison.OrdinalIgnoreCase) == true; + var hasRecompile = MaskCommentsAndLiterals(stmt.StatementText) // #579 + .Contains("RECOMPILE", StringComparison.OrdinalIgnoreCase); if (!hasRecompile) { var names = string.Join(", ", unsnifffedParams.Select(p => p.Name)); stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 20, WarningType = "Local Variables", Message = $"Local variables detected: {names}. SQL Server cannot sniff local variable values at compile time, so it uses average density estimates instead of your actual values. Test with OPTION (RECOMPILE) to see if the plan improves. For a permanent fix, use dynamic SQL or a stored procedure to pass the values as parameters instead of local variables.", Severity = PlanWarningSeverity.Warning @@ -330,10 +346,11 @@ private static void Rule27_OptimizeForUnknown(PlanStatement stmt, AnalyzerConfig { // Rule 27: OPTIMIZE FOR UNKNOWN in statement text if (!cfg.IsRuleDisabled(27) && !string.IsNullOrEmpty(stmt.StatementText) && - Regex.IsMatch(stmt.StatementText, @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)) + Regex.IsMatch(MaskCommentsAndLiterals(stmt.StatementText), @"OPTIMIZE\s+FOR\s+UNKNOWN", RegexOptions.IgnoreCase)) // #579 { stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 27, WarningType = "Optimize For Unknown", Message = "OPTIMIZE FOR UNKNOWN uses average density estimates instead of sniffed parameter values. This can help when parameter sniffing causes plan instability, but may produce suboptimal plans for skewed data distributions.", Severity = PlanWarningSeverity.Warning @@ -354,6 +371,7 @@ private static void Rule36_DynamicCursor(PlanStatement stmt, AnalyzerConfig cfg, var cursorLabel = string.IsNullOrEmpty(stmt.CursorName) ? "Cursor" : $"Cursor \"{stmt.CursorName}\""; stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 36, WarningType = "Dynamic Cursor", Message = $"{cursorLabel} is a dynamic cursor. Dynamic cursors tolerate underlying data changes between fetches, which prevents many index uses and forces extra work per fetch. If you don't need that semantic, switching to FAST_FORWARD (or STATIC / KEYSET, depending on requirements) typically gives a large performance improvement.", Severity = PlanWarningSeverity.Warning @@ -376,10 +394,12 @@ private static void Rule37_CursorWithoutLocal(PlanStatement stmt, AnalyzerConfig // qualifier must be looked for between CURSOR and the FOR that introduces // the SELECT. Capturing tokens *before* CURSOR never sees LOCAL and would // fire on every cursor, including ones already declared LOCAL. + // NonBacktracking: with no FOR after them, a long run of DECLARE ... CURSOR took time + // in proportion to the square of the statement's length (see PlanAnalyzer.cs). var cursorDeclMatch = Regex.Match( - stmt.StatementText, + MaskCommentsAndLiterals(stmt.StatementText), // #579 @"\bDECLARE\s+\w+\s+(?:INSENSITIVE\s+|SCROLL\s+)*CURSOR\b(.*?)\bFOR\b", - RegexOptions.IgnoreCase | RegexOptions.Singleline); + RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.NonBacktracking); if (cursorDeclMatch.Success) { var qualifiers = cursorDeclMatch.Groups[1].Value; @@ -387,6 +407,7 @@ private static void Rule37_CursorWithoutLocal(PlanStatement stmt, AnalyzerConfig { stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 37, WarningType = "Cursor Missing LOCAL", Message = "CURSOR declaration is missing the LOCAL keyword. Default cursor scope is GLOBAL, which puts the cursor in a shared namespace and can bloat the plan cache (see https://erikdarling.com/cursor-declarations-that-use-openjson-can-bloat-your-plan-cache/). Adding LOCAL is cheap and usually right.", Severity = PlanWarningSeverity.Warning @@ -407,7 +428,7 @@ private static void Rule38_StandardEditionDop(PlanStatement stmt, AnalyzerConfig // Suppress when the user explicitly set MAXDOP 2 as a query hint — the DOP // cap is intentional, not the Standard Edition batch-mode limitation. var hasMaxdop2Hint = !string.IsNullOrEmpty(stmt.StatementText) - && Regex.IsMatch(stmt.StatementText, @"MAXDOP\s+2\b", RegexOptions.IgnoreCase); + && Regex.IsMatch(MaskCommentsAndLiterals(stmt.StatementText), @"MAXDOP\s+2\b", RegexOptions.IgnoreCase); // #579 if (!hasMaxdop2Hint) { @@ -420,6 +441,7 @@ private static void Rule38_StandardEditionDop(PlanStatement stmt, AnalyzerConfig { stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 38, WarningType = "Standard Edition DOP Limitation", Message = $"DOP is limited to 2 because SQL Server Standard Edition caps parallelism at 2 when batch mode operators are present, even though MAXDOP is set to {serverMetadata.MaxDop}. Developer or Enterprise Edition would allow higher DOP in the same conditions.", Severity = PlanWarningSeverity.Warning @@ -431,6 +453,7 @@ private static void Rule38_StandardEditionDop(PlanStatement stmt, AnalyzerConfig // No server context, or edition unknown (e.g. collection failure) — suspect the limitation stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 38, WarningType = "Standard Edition DOP Limitation", Message = "DOP is limited to 2 and the plan uses batch mode operators. This may be caused by the SQL Server Standard Edition limitation, which caps parallelism at 2 when batch mode is in use. If this server runs Standard Edition, Developer or Enterprise Edition would allow higher DOP.", Severity = PlanWarningSeverity.Info @@ -451,8 +474,10 @@ private static void Rule30_MissingIndexQuality(PlanStatement stmt, AnalyzerConfi if (!cfg.IsRuleDisabled(30)) { // Detect duplicate suggestions for the same table + /* #578: the database is part of the key. dbo.T in database A and dbo.T in database B + are two tables, and their indexes cannot be consolidated into one. */ var tableSuggestionCount = stmt.MissingIndexes - .GroupBy(mi => $"{mi.Schema}.{mi.Table}", StringComparer.OrdinalIgnoreCase) + .GroupBy(mi => $"{mi.Database}.{mi.Schema}.{mi.Table}", StringComparer.OrdinalIgnoreCase) .Where(g => g.Count() > 1) .ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase); @@ -460,13 +485,14 @@ private static void Rule30_MissingIndexQuality(PlanStatement stmt, AnalyzerConfi { var keyCount = mi.EqualityColumns.Count + mi.InequalityColumns.Count; var includeCount = mi.IncludeColumns.Count; - var tableKey = $"{mi.Schema}.{mi.Table}"; + var tableKey = $"{mi.Database}.{mi.Schema}.{mi.Table}"; // Low-impact suggestion (< 25% improvement) if (mi.Impact < 25) { stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 30, WarningType = "Low Impact Index", Message = $"Missing index suggestion for {mi.Table} has only {mi.Impact:F0}% estimated impact. Low-impact indexes add maintenance overhead (insert/update/delete cost) that may not justify the modest query improvement.", Severity = PlanWarningSeverity.Info @@ -478,6 +504,7 @@ private static void Rule30_MissingIndexQuality(PlanStatement stmt, AnalyzerConfi { stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 30, WarningType = "Wide Index Suggestion", Message = $"Missing index suggestion for {mi.Table} has {includeCount} INCLUDE columns. This is a \"kitchen sink\" index — SQL Server suggests covering every column the query touches, but the resulting index would be very wide and expensive to maintain. Evaluate which columns are actually needed, or consider a narrower index with fewer includes.", Severity = PlanWarningSeverity.Warning @@ -488,6 +515,7 @@ private static void Rule30_MissingIndexQuality(PlanStatement stmt, AnalyzerConfi { stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 30, WarningType = "Wide Index Suggestion", Message = $"Missing index suggestion for {mi.Table} has {keyCount} key columns ({mi.EqualityColumns.Count} equality + {mi.InequalityColumns.Count} inequality). Wide key columns increase index size and maintenance cost. Evaluate whether all key columns are needed for seek predicates.", Severity = PlanWarningSeverity.Warning @@ -499,6 +527,7 @@ private static void Rule30_MissingIndexQuality(PlanStatement stmt, AnalyzerConfi { stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 30, WarningType = "Duplicate Index Suggestions", Message = $"{count} missing index suggestions target {mi.Table}. Multiple suggestions for the same table often overlap — consolidate into fewer, broader indexes rather than creating all of them.", Severity = PlanWarningSeverity.Warning @@ -529,6 +558,7 @@ private static void Rule22Stmt_TableVariable(PlanStatement stmt, AnalyzerConfig { stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 22, WarningType = "Table Variable", Message = "Table variable detected. Table variables lack column-level statistics, which causes bad row estimates, join choices, and memory grant decisions. Replace with a #temp table.", Severity = PlanWarningSeverity.Warning, @@ -540,6 +570,7 @@ private static void Rule22Stmt_TableVariable(PlanStatement stmt, AnalyzerConfig { stmt.PlanWarnings.Add(new PlanWarning { + RuleNumber = 22, WarningType = "Table Variable", Message = "This query modifies a table variable, which forces the entire plan to run single-threaded. SQL Server cannot use parallelism for modifications to table variables. Replace with a #temp table to allow parallel execution.", Severity = PlanWarningSeverity.Critical, diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Timing.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Timing.cs index 386d188d..4ce169fd 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Timing.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Timing.cs @@ -350,23 +350,70 @@ private static string QuantifyFilterImpact(PlanNode filterNode) return string.Join("\n", parts.Select(p => "• " + p)); } + // Rule 33 only looks at tables with at least this many rows, and DetectCeGuess relies on that: + // the equality guess is a power of the row count, so on a small table it lands on the fixed + // guesses below, and from this size up it stays clear of them (0.3% and 5.6% at this size, + // falling as the table grows). + private const double CeGuessMinTableRows = 100_000; + /// /// Detects well-known CE default selectivity guesses by comparing EstimateRows to TableCardinality. /// Returns a description of the guess pattern, or null if no known pattern matches. + /// + /// Where each guess comes from was measured on SQL Server 2025: a 100,000-row heap with no + /// statistics, estimated plans through SET SHOWPLAN_XML, CE 70 through + /// FORCE_LEGACY_CARDINALITY_ESTIMATION and CE 120 to 170 through the compatibility level. The + /// versions from 120 to 170 agree except where a row says otherwise. + /// equality, a = 5 or a IS NULL CE 120+: rows^0.5 CE 70: rows^0.75 + /// inequality, a > 5 30%, every CE + /// BETWEEN or a two-sided range CE 70: 9% CE 120+: 9% on a column with known values + /// LIKE CE 120+: 9% CE 70: not a fixed guess + /// two inequalities, two columns CE 120+: 16.43% CE 70: 9% + /// range on variables, or on an expression, ABS(a) BETWEEN 5 AND 10 CE 120+: 16.43% CE 70: 9% + /// one column compared with another 10%, every CE + /// equality on an expression, ABS(a) = 5 CE 130+: 10% CE 120: rows^0.5 CE 70: rows^0.75 + /// two 10% guesses, a = b AND c = d CE 120+: 3.16% CE 70: 1% + /// The 16.43% is 30% times the square root of 30%, how CE 120+ combines two 30% guesses. /// - private static string? DetectCeGuess(double estimateRows, double tableCardinality) + /// + /// The statement's CardinalityEstimationModelVersion: 70 is the legacy estimator, 120 and later + /// the current one, and 0 means the plan did not say, so both stay possible. + /// + private static string? DetectCeGuess(double estimateRows, double tableCardinality, int ceModelVersion = 0) { if (tableCardinality <= 0) return null; var selectivity = estimateRows / tableCardinality; + var pct = $"{selectivity * 100:N1}%"; + var legacy = ceModelVersion == 70; + var current = ceModelVersion >= 120; + + // Equality is not a fixed share of the table, so it is checked on its own, to 1%. The two + // estimators use different powers of the row count, so a plan that names its estimator only + // gets the one that estimator uses. + static bool Near(double rows, double guess) => Math.Abs(rows - guess) <= guess * 0.01; + + if (!legacy && Near(estimateRows, Math.Sqrt(tableCardinality))) + return $"matches the equality guess (an equality or IS NULL with no statistics to use), the square root of the row count ({pct})"; + if (!current && Near(estimateRows, Math.Pow(tableCardinality, 0.75))) + return $"matches the equality guess (an equality or IS NULL with no statistics to use), the row count to the power 0.75 ({pct})"; - // Known CE guess selectivities with a 2% tolerance band + // The fixed guesses, with a 2% tolerance band return selectivity switch { - >= 0.29 and <= 0.31 => $"matches the 30% equality guess ({selectivity * 100:N1}%)", - >= 0.098 and <= 0.102 => $"matches the 10% inequality guess ({selectivity * 100:N1}%)", - >= 0.088 and <= 0.092 => $"matches the 9% LIKE/BETWEEN guess ({selectivity * 100:N1}%)", - >= 0.155 and <= 0.175 => $"matches the ~16.4% compound predicate guess ({selectivity * 100:N1}%)", - >= 0.009 and <= 0.011 => $"matches the 1% multi-inequality guess ({selectivity * 100:N1}%)", + >= 0.29 and <= 0.31 => + $"matches the 30% guess for an inequality such as > or < ({pct})", + >= 0.088 and <= 0.092 => + current ? $"matches the 9% guess for BETWEEN or a two-sided range on a column with known values, or for LIKE ({pct})" + : legacy ? $"matches the 9% guess for BETWEEN, a two-sided range, or two inequalities on different columns ({pct})" + : $"matches the 9% guess for BETWEEN or a two-sided range ({pct})", + >= 0.098 and <= 0.102 => + ceModelVersion is 0 or >= 130 + ? $"matches the 10% guess for comparing one column with another, or for an equality on an expression such as a function of a column ({pct})" + : $"matches the 10% guess for comparing one column with another ({pct})", + >= 0.155 and <= 0.175 when !legacy => + $"matches the 16.4% guess for two inequalities on different columns, or for a BETWEEN or range on variables or on an expression ({pct})", + >= 0.009 and <= 0.011 when !current => + $"matches the 1% guess that CE 70 gets from multiplying two 10% guesses, as when two predicates each compare one column with another ({pct})", _ => null }; } diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index d9a58004..589b4429 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -12,13 +12,21 @@ namespace PlanViewer.Core.Services; /// public static partial class PlanAnalyzer { + /* Plan XML can come from anywhere, so a predicate or a statement can be millions of characters + long. The patterns below marked NonBacktracking read up to a closing bracket, quote or + keyword. When that is missing, the default engine reads from every possible start to the + end of the text, which takes time in proportion to the square of its length. The + NonBacktracking engine finds the same matches in time that grows only in step with the + length. It has no lookarounds, so + ComparisonOperatorRegex keeps the default engine; its matches are at most two characters + long, so it is linear already. */ private static readonly Regex FunctionInPredicateRegex = new( @"\b(CONVERT_IMPLICIT|CONVERT|CAST|isnull|coalesce|datepart|datediff|dateadd|year|month|day|upper|lower|ltrim|rtrim|trim|substring|left|right|charindex|replace|len|datalength|abs|floor|ceiling|round|reverse|stuff|format)\s*\(", RegexOptions.IgnoreCase | RegexOptions.Compiled); private static readonly Regex LeadingWildcardLikeRegex = new( @"\blike\b[^'""]*?N?'%", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + RegexOptions.IgnoreCase | RegexOptions.NonBacktracking); private static readonly Regex CaseInPredicateRegex = new( @"\bCASE\s+(WHEN\b|$)", @@ -44,7 +52,7 @@ in from another unaliased table variable one row at a time (#564) — that one l the same and this regex could never have told the two apart either. */ private static readonly Regex ColumnReferenceRegex = new( @"\[[^\]]+\]\.\[", - RegexOptions.Compiled); + RegexOptions.NonBacktracking); /* An optimizer-generated expression name in a ScalarString ([Expr1003]) — a computed value, never an actual column, even on a table variable scan where a bare name is otherwise read @@ -61,7 +69,7 @@ as a column (#561). Matched against BracketedNameRegex's whole name group, so it the right place. */ private static readonly Regex NamePartRegex = new( @"\[(?:[^\]]|\]\])*\]", - RegexOptions.Compiled); + RegexOptions.NonBacktracking); /* The operator a comparison turns on in a ScalarString: >=, <=, <>, !=, >, <, = or like. Without like, [col] like upper([@p]) had no operator at all, fell to the assume-the-worst @@ -76,7 +84,7 @@ the right place. */ Groups[1] match is a real operator. */ private static readonly Regex LogicalOperatorRegex = new( @"'(?:[^']|'')*'|\[(?:[^\]]|\]\])*\]|\s(AND|OR)\s", - RegexOptions.IgnoreCase | RegexOptions.Compiled); + RegexOptions.IgnoreCase | RegexOptions.NonBacktracking); private static readonly Regex IsnullCoalesceRegex = new( @"\b(isnull|coalesce)\s*\(", @@ -88,7 +96,7 @@ Groups[1] match is a real operator. */ name group is a name. */ private static readonly Regex BracketedNameRegex = new( @"'(?:[^']|'')*'|(?\[(?:[^\]]|\]\])*\](?:\.\[(?:[^\]]|\]\])*\])*)(?\s*\()?", - RegexOptions.Compiled); + RegexOptions.NonBacktracking); public static void Analyze(ParsedPlan plan, AnalyzerConfig? config = null, ServerMetadata? serverMetadata = null) => AnalyzeCancellable(plan, config, serverMetadata, CancellationToken.None); @@ -154,57 +162,6 @@ statement with nothing to say about it. */ "Implicit Conversion", }; - - // Rule number → WarningType mapping for severity overrides - private static readonly Dictionary RuleWarningTypes = new() - { - [1] = "Filter Operator", - [2] = "Eager Index Spool", - [3] = "Serial Plan", - [4] = "UDF Execution", - [5] = "Row Estimate Mismatch", - [6] = "Scalar UDF", - [7] = "Spill", - [8] = "Parallel Skew", - [9] = "Memory Grant", - [10] = "Key Lookup", - [11] = "Scan With Predicate", - [12] = "Non-SARGable Predicate", - [13] = "Data Type Mismatch", - [14] = "Lazy Spool Ineffective", - [15] = "Join OR Clause", - [16] = "Nested Loops High Executions", - [17] = "Many-to-Many Merge Join", - [18] = "Compile Memory Exceeded", - [19] = "High Compile CPU", - [20] = "Local Variables", - [22] = "Table Variable", - [23] = "Table-Valued Function", - [24] = "Top Above Scan", - [25] = "Ineffective Parallelism", - [26] = "Row Goal", - [27] = "Optimize For Unknown", - [28] = "NOT IN with Nullable Column", - [29] = "Implicit Conversion", - [30] = "Wide Index Suggestion", - [31] = "Parallel Wait Bottleneck", - [32] = "Scan Cardinality Misestimate", - [33] = "Estimated Plan CE Guess", - [38] = "Standard Edition DOP Limitation" - }; - - // Reverse lookup: WarningType → rule number - private static readonly Dictionary WarningTypeToRule; - - static PlanAnalyzer() - { - WarningTypeToRule = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var (rule, type) in RuleWarningTypes) - WarningTypeToRule[type] = rule; - } - - - /// private record ScanImpact(double CostPct, double ElapsedPct, string? Summary); diff --git a/src/PlanViewer.Core/Services/PlanRowAccuracy.cs b/src/PlanViewer.Core/Services/PlanRowAccuracy.cs new file mode 100644 index 00000000..1e3ca737 --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanRowAccuracy.cs @@ -0,0 +1,110 @@ +using System; +using System.Globalization; + +namespace PlanViewer.Core.Services; + +/// +/// The plan viewer's node row line: the rows an operator actually returned against the rows it was +/// expected to, printed as "{actual} of {expected} ({percent}%)". The expected figure is +/// , a total like ActualRows (#594). Shared by +/// the App and Web node labels and the HTML export (#611). PerformanceMonitor's plan viewer ports +/// the same rule (its PlanRowAccuracy, #4684), so both apps print the same string for the +/// same plan. +/// +public static class PlanRowAccuracy +{ + /// The most decimals adds to make a label agree with its percentage. + private const int MaxAgreementDecimals = 4; + + /// + /// The node row line: "{actual} of {expected} ({percent}%)", or just "{actual} of {expected}" + /// when is not above zero. See for the rule. + /// A Key Lookup that ran 117 times for 1 row (estimate 0.00964372 each) reads 1 of 1.128 (89%), not the + /// 1 of 1 (89%) that printing both counts N0 gave. + /// + public static string FormatActualOfExpected(double actualRows, double expectedRows, IFormatProvider? provider = null) + { + var (actual, expected, percent) = PrintActualOfExpected(actualRows, expectedRows, provider); + return percent == null + ? string.Concat(actual, " of ", expected) + : string.Concat(actual, " of ", expected, " (", percent, "%)"); + } + + /// + /// The two row counts and the whole percentage that joins, for a surface + /// that words the line its own way (the HTML export adds "rows"). The rule, word for word: + /// Print N0. If the whole percent computed from the printed numbers differs from the printed percentage, add the + /// fewest decimals (fixed-point, never scientific, capped at 4) at which they agree. A non-zero value never prints as + /// zero. If it would, print it fixed-point to its first significant digit. + /// + /// The percentage is actualRows / expectedRows * 100 to a whole number, from the unrounded values. It is + /// null when is not above zero (there is nothing to divide by). + /// The agreement search runs from 0 to 4 decimals. At each count a whole number prints N0 and any other + /// number prints N plus that count. The text is parsed back (group separator allowed, so "2,983" reads as + /// 2983) and the search stops at the first count where the whole percent of the printed numbers equals the printed + /// percentage. A printed divisor of zero never agrees. At 4 it stops either way, so a value like 0.00012345 can + /// print a line that still contradicts its percentage: the cap wins. + /// A non-zero value whose text would read as zero is reprinted with as many decimals as it takes to show its + /// first significant digit (0.000005 prints "0.000005"); the cap does not apply to that. + /// Only N formats print the row counts, so no magnitude prints an exponent. Culture follows + /// , the current culture by default. + /// + /// + public static (string Actual, string Expected, string? Percent) PrintActualOfExpected( + double actualRows, double expectedRows, IFormatProvider? provider = null) + { + provider ??= CultureInfo.CurrentCulture; + + var percent = expectedRows > 0 ? WholePercent(actualRows, expectedRows, provider) : null; + + var actualText = ""; + var expectedText = ""; + for (var decimals = 0; decimals <= MaxAgreementDecimals; decimals++) + { + actualText = PrintRows(actualRows, decimals, provider); + expectedText = PrintRows(expectedRows, decimals, provider); + + if (percent == null || PrintedNumbersGive(percent, actualText, expectedText, provider)) + break; + } + + return (actualText, expectedText, percent); + } + + private static string WholePercent(double actualRows, double expectedRows, IFormatProvider provider) + => (actualRows / expectedRows * 100).ToString("F0", provider); + + /// + /// One row count as text: a whole number is always N0, any other number N{decimals}. A non-zero + /// value that would print as zero is reprinted to its first significant digit instead. + /// + private static string PrintRows(double value, int decimals, IFormatProvider provider) + { + var text = value.ToString(Math.Floor(value) == value ? "N0" : NFormat(decimals), provider); + + if (value != 0 && double.IsFinite(value) + && double.TryParse(text, NumberStyles.Number, provider, out var printed) && printed == 0) + { + var firstSignificantDecimals = -(int)Math.Floor(Math.Log10(Math.Abs(value))); + text = value.ToString(NFormat(firstSignificantDecimals), provider); + } + + return text; + } + + /// True when the whole percent of the two printed numbers is the printed percentage. + private static bool PrintedNumbersGive(string percent, string actualText, string expectedText, IFormatProvider provider) + { + if (!double.TryParse(actualText, NumberStyles.Number, provider, out var printedActual) + || !double.TryParse(expectedText, NumberStyles.Number, provider, out var printedExpected)) + { + return false; + } + + // A printed divisor of zero gives NaN or Infinity, which never equals a percentage. + var printedPercent = printedActual / printedExpected * 100; + return double.IsFinite(printedPercent) && printedPercent.ToString("F0", provider) == percent; + } + + private static string NFormat(int decimals) => string.Create(CultureInfo.InvariantCulture, $"N{decimals}"); +} diff --git a/src/PlanViewer.Core/Services/PlanStatements.cs b/src/PlanViewer.Core/Services/PlanStatements.cs index ca347719..80d2432b 100644 --- a/src/PlanViewer.Core/Services/PlanStatements.cs +++ b/src/PlanViewer.Core/Services/PlanStatements.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; +using System.Linq; using PlanViewer.Core.Models; +using PlanViewer.Core.Output; namespace PlanViewer.Core.Services; @@ -113,6 +115,26 @@ private static void PushAll(FunctionPlanInfo body, string? outerPath, Stack " + procName; } + + private const string NoStatements = "Could not parse any statements from the plan XML"; + + /// + /// The message to show when has no statement to analyze, or null when it + /// has at least one. XML that is well formed but is not a showplan, and a showplan with no + /// statements, both parse without a and reach this state. + /// The web page indexed the first statement without checking, so it threw a + /// NullReferenceException while it drew the result. Uses the same traversal as the result + /// mapper, so this is null exactly when the mapped result has at least one statement. + /// + public static string? NoStatementsMessage(ParsedPlan plan) => + EnumerateAll(plan).Any() ? null : NoStatements; + + /// + /// The same check for a result that was mapped earlier or read back from the share server, + /// where there is no parsed plan to look at. + /// + public static string? NoStatementsMessage(AnalysisResult result) => + result.Statements.Count > 0 ? null : NoStatements; } /// diff --git a/src/PlanViewer.Core/Services/PlanXml.cs b/src/PlanViewer.Core/Services/PlanXml.cs new file mode 100644 index 00000000..e223f43a --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanXml.cs @@ -0,0 +1,220 @@ +using System.Buffers; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Linq; + +namespace PlanViewer.Core.Services; + +/// +/// Loads plan XML the same way everywhere. A plan can come from a file, the clipboard, a shared +/// link or an MCP call, so it is read with no DTD and no resolver, and within limits on its size +/// and nesting. A limit that is passed throws an , as malformed XML +/// does, so every caller already handles it. +/// +internal static class PlanXml +{ + /// The most characters plan XML can have. + internal const int MaxCharacters = 16 * 1024 * 1024; + + /// + /// How deep an element can be nested: 8 levels for each of the + /// levels the parser accepts. The deepest real + /// plan measured nests 75 levels. + /// + internal const int MaxDepth = 8 * 1024; + + /// + /// The most that the depths of all nodes can add up to. XDocument checks each node it adds + /// against every ancestor, so its load time grows with this sum, not with the size of the + /// XML: a few elements nested tens of thousands deep took minutes to load. At this limit a + /// load takes under a second. The largest real plans measured come to about 100,000. + /// + internal const long MaxDepthSum = 1L << 29; + + /// + /// The longest namespace URI that can be declared. XDocument looks a namespace up by its + /// whole URI each time the namespace changes from one name to the next, so a long URI used + /// by alternating names costs its length again at every change: a 1.4M-character document + /// took 25 seconds. Real plans use three namespaces, the longest 55 characters. + /// + internal const int MaxNamespaceLength = 256; + + /// + /// The most attributes one element can have. XmlReader reads a whole start tag before it + /// can say how many attributes the tag has, and a tag with about a million of them took it + /// 10 to 40 seconds, so they are counted in the text first (). + /// Real plans have at most 20 on one element. + /// + internal const int MaxAttributes = 1024; + + private const string XmlnsNamespace = "http://www.w3.org/2000/xmlns/"; + + /// What ends a step through a start tag: an attribute's "=", a quote, or the tag's end. + private static readonly SearchValues StartTagStops = SearchValues.Create("=\"'>"); + + internal static XDocument Parse(string xml) + { + CheckLimits(xml); + using var reader = XmlReader.Create(new StringReader(xml), ReaderSettings(async: false)); + return XDocument.Load(reader); + } + + internal static async Task ParseAsync(string xml, CancellationToken cancellationToken) + { + CheckLimits(xml); + using var reader = XmlReader.Create(new StringReader(xml), ReaderSettings(async: true)); + return await XDocument.LoadAsync(reader, LoadOptions.None, cancellationToken).ConfigureAwait(false); + } + + /// + /// Reads the XML once without building anything, which takes time in step with its length, + /// and throws before XDocument starts on XML that is too large, too deeply nested, or that + /// passes one of the limits on an element's attributes. + /// + private static void CheckLimits(string xml) + { + ArgumentNullException.ThrowIfNull(xml); + + if (xml.Length > MaxCharacters) + throw new XmlException( + $"Plan XML exceeds the supported size limit of {MaxCharacters.ToString("N0", CultureInfo.InvariantCulture)} characters."); + + CheckAttributeCounts(xml); + + using var reader = XmlReader.Create(new StringReader(xml), ReaderSettings(async: false)); + long depthSum = 0; + while (reader.Read()) + { + if (reader.NodeType == XmlNodeType.EndElement) + continue; + + if (reader.Depth > MaxDepth) + throw new XmlException( + $"Plan XML exceeds the supported depth limit of {MaxDepth.ToString("N0", CultureInfo.InvariantCulture)} levels."); + + depthSum += reader.Depth; + if (depthSum > MaxDepthSum) + throw new XmlException("Plan XML has too many deeply nested elements."); + + if (reader.NodeType == XmlNodeType.Element && reader.HasAttributes) + CheckAttributes(reader); + } + } + + /// + /// Counts the attributes of each start tag in the text and throws at the first tag with more + /// than , in one pass and before XmlReader reads anything. Every + /// attribute has exactly one "=" outside its quoted value, so the count is the number of "=" + /// between a start tag's "<" and its ">" that are not inside quotes. Comments, CDATA + /// sections, processing instructions, declarations and end tags are stepped over. Text that is + /// not well formed stops the count, and XmlReader reports it. + /// + internal static void CheckAttributeCounts(string xml) + { + var text = xml.AsSpan(); + var i = 0; + + while (true) + { + var open = text[i..].IndexOf('<'); + if (open < 0) + return; + i += open + 1; + + var rest = text[i..]; + if (rest.StartsWith("!--", StringComparison.Ordinal)) + i = SkipPast(text, i + 3, "-->"); + else if (rest.StartsWith("![CDATA[", StringComparison.Ordinal)) + i = SkipPast(text, i + 8, "]]>"); + else if (rest.StartsWith("?", StringComparison.Ordinal)) + i = SkipPast(text, i + 1, "?>"); + else if (rest.StartsWith("!", StringComparison.Ordinal) || rest.StartsWith("/", StringComparison.Ordinal)) + i = SkipPast(text, i + 1, ">"); + else + i = SkipStartTag(text, i); + + if (i < 0) + return; + } + } + + /// + /// Steps through one start tag from just after its "<", counting its attributes. Returns + /// the index after the tag's ">", or -1 at the end of the text. + /// + private static int SkipStartTag(ReadOnlySpan text, int i) + { + var attributes = 0; + + while (true) + { + var stop = text[i..].IndexOfAny(StartTagStops); + if (stop < 0) + return -1; + i += stop; + + switch (text[i]) + { + case '>': + return i + 1; + + case '=': + if (++attributes > MaxAttributes) + throw TooManyAttributes(); + i++; + break; + + default: + var close = text[(i + 1)..].IndexOf(text[i]); + if (close < 0) + return -1; + i += close + 2; + break; + } + } + } + + /// The index after the first at or past , or -1. + private static int SkipPast(ReadOnlySpan text, int i, string end) + { + if (i > text.Length) + return -1; + + var found = text[i..].IndexOf(end, StringComparison.Ordinal); + return found < 0 ? -1 : i + found + end.Length; + } + + private static XmlException TooManyAttributes() => + new($"Plan XML has an element with more than {MaxAttributes.ToString("N0", CultureInfo.InvariantCulture)} attributes."); + + private static void CheckAttributes(XmlReader reader) + { + if (reader.AttributeCount > MaxAttributes) + throw TooManyAttributes(); + + while (reader.MoveToNextAttribute()) + { + if (reader.NamespaceURI == XmlnsNamespace && reader.Value.Length > MaxNamespaceLength) + throw new XmlException( + $"Plan XML declares a namespace longer than {MaxNamespaceLength.ToString("N0", CultureInfo.InvariantCulture)} characters."); + } + + reader.MoveToElement(); + } + + /// + /// The settings XDocument.Parse uses, except that a DTD is refused rather than processed + /// (plans never have one) and the size limit applies. + /// + private static XmlReaderSettings ReaderSettings(bool async) => new() + { + Async = async, + DtdProcessing = DtdProcessing.Prohibit, + IgnoreWhitespace = true, + MaxCharactersInDocument = MaxCharacters, + XmlResolver = null + }; +} diff --git a/src/PlanViewer.Core/Services/ReproScriptBuilder.cs b/src/PlanViewer.Core/Services/ReproScriptBuilder.cs index a7f8a59b..a7c92298 100644 --- a/src/PlanViewer.Core/Services/ReproScriptBuilder.cs +++ b/src/PlanViewer.Core/Services/ReproScriptBuilder.cs @@ -63,10 +63,38 @@ public static string BuildReproScript( off ParameterList attributes. Drop parameters whose name or type isn't a plausible T-SQL token before anything is interpolated — the name lands in the warning comment and the sp_executesql assignment list. */ - var safeParameters = parameters + var validParameters = parameters .Where(p => IsValidParameterName(p.Name) && IsValidDataType(p.DataType)) .ToList(); + /* A batch's plan lists each statement's parameters: a parameter once for every + statement that uses it, and each auto-parameterized statement's own @0 or @1, + typed by that statement's literal. Declare each name once; a second declaration + fails the script. A name that the statements give different types can't be + declared once, so it is left out. Those statements are usually literal text + that doesn't use the name. */ + var parameterGroups = validParameters + .GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + var conflictingNames = parameterGroups + .Where(g => g.Select(p => p.DataType).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1) + .Select(g => g.Key) + .ToList(); + var declarableGroups = parameterGroups + .Where(g => !conflictingNames.Contains(g.Key, StringComparer.OrdinalIgnoreCase)) + .ToList(); + + /* Statements recompiled at different times can carry different compiled values for + the same parameter, and some carry none. Use the first value that can go into + the script as it is, and say so when the statements disagree. */ + var safeParameters = declarableGroups + .Select(g => g.FirstOrDefault(p => !string.IsNullOrEmpty(p.CompiledValue) && IsSafeLiteral(p.CompiledValue)) ?? g.First()) + .ToList(); + var differingValueNames = declarableGroups + .Where(g => g.Select(p => p.CompiledValue).Where(v => !string.IsNullOrEmpty(v)).Distinct(StringComparer.Ordinal).Count() > 1) + .Select(g => g.Key) + .ToList(); + /* Check for temp tables and table variables in query text */ var tempTableWarnings = DetectTempTablesAndTableVariables(queryText); warnings.AddRange(tempTableWarnings); @@ -90,12 +118,22 @@ rather than leaving an unexplained placeholder. */ /* Parameters dropped entirely because the plan's name or data type wasn't a plain T-SQL token — the script would be incomplete, so don't stay silent. */ - var droppedCount = parameters.Count - safeParameters.Count; + var droppedCount = parameters.Count - validParameters.Count; if (droppedCount > 0) { warnings.Add($"{droppedCount} parameter(s) omitted — the plan's parameter name or data type was not a valid T-SQL identifier. Declare them manually before executing."); } + if (conflictingNames.Count > 0) + { + warnings.Add($"Parameters with a different data type in different statements (left out): {string.Join(", ", conflictingNames)}. Declare them manually if the query uses them."); + } + + if (differingValueNames.Count > 0) + { + warnings.Add($"Parameters with a different compiled value in different statements (set to the first usable one): {string.Join(", ", differingValueNames)}. Check the values before executing."); + } + /* Check for local variables: query has parameter prefix but plan has no/few parameters */ var trimmedQuery = queryText.Trim(); var cleanedQuery = StripParameterPrefix(trimmedQuery); @@ -111,13 +149,14 @@ rather than leaving an unexplained placeholder. */ warnings.Add($"Variables in query without values: {string.Join(", ", unresolvedVars)}. These may be local variables — fill in values before executing."); } - /* Header comment */ + /* Header comment. Every value in it goes through CommentSafe: the database name + comes off plan XML, and a crafted one must not be able to end the comment early. */ sb.AppendLine("/*"); - sb.AppendLine("Reproduction script generated by SQL Server Performance Monitor"); - sb.AppendLine($"Source: {source}"); + sb.AppendLine("Reproduction script generated by Performance Studio"); + sb.AppendLine($"Source: {CommentSafe(source)}"); if (!string.IsNullOrEmpty(databaseName)) { - sb.AppendLine($"Database: [{databaseName}]"); + sb.AppendLine($"Database: [{CommentSafe(databaseName)}]"); } sb.AppendLine($"Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); @@ -127,7 +166,7 @@ rather than leaving an unexplained placeholder. */ sb.AppendLine("Warnings:"); foreach (var warning in warnings) { - sb.AppendLine($" - {warning}"); + sb.AppendLine($" - {CommentSafe(warning)}"); } } @@ -135,7 +174,8 @@ rather than leaving an unexplained placeholder. */ sb.AppendLine(); /* USE database (skip for Azure SQL DB — USE is invalid there). - Double any ']' in the identifier so names like 'cool]stuff' still parse. */ + Double any ']' in the identifier so names like 'cool]stuff' still parse. Line breaks + in the name stay: a client that splits batches correctly never splits inside brackets. */ if (!string.IsNullOrEmpty(databaseName) && !isAzureSqlDb) { sb.AppendLine($"USE [{databaseName.Replace("]", "]]")}];"); @@ -188,8 +228,11 @@ with wrong data */ } else if (!string.IsNullOrEmpty(planXml)) { - /* Plan was available but had no parameters — query is not parameterized */ - sb.AppendLine("/* No parameters found in plan cache */"); + /* Plan was available but had no parameters — query is not parameterized — + or none of its parameters could be declared, and the warnings say why. */ + sb.AppendLine(parameters.Count == 0 + ? "/* No parameters found in plan cache */" + : "/* No parameters declared: see the warnings above */"); sb.AppendLine(cleanedQuery); if (!cleanedQuery.EndsWith(';')) { @@ -275,7 +318,7 @@ public static List ExtractParametersFromPlan(string planXml) try { - var doc = XDocument.Parse(planXml); + var doc = PlanXml.Parse(planXml); XNamespace ns = "http://schemas.microsoft.com/sqlserver/2004/07/showplan"; /* Find all ColumnReference elements under ParameterList */ @@ -326,7 +369,7 @@ private static List ExtractSetOptionsFromPlan(string planXml) try { - var doc = XDocument.Parse(planXml); + var doc = PlanXml.Parse(planXml); XNamespace ns = "http://schemas.microsoft.com/sqlserver/2004/07/showplan"; var setOptsEl = doc.Descendants(ns + "StatementSetOptions").FirstOrDefault(); @@ -409,22 +452,48 @@ private static string EscapeSqlString(string value) } /// - /// Validates a parameter name from plan XML as a plain @identifier. - /// Anything else is dropped from the generated script. + /// Makes text safe inside the header's block comment. "*/" would close the comment and + /// "/*" would open a nested one (T-SQL block comments nest), so both are split with a + /// space. Line breaks become spaces too, so each value stays on one line of the header + /// and cannot put GO on a line of its own there. + /// + private static string CommentSafe(string? text) + { + /* \p{Cc} covers CR, LF, tab and NEL; U+2028 and U+2029 are the Unicode line and + paragraph separators, which some editors also treat as line breaks. */ + return Regex.Replace(text ?? "", @"[\p{Cc}\u2028\u2029]", " ") + .Replace("*/", "* /") + .Replace("/*", "/ *"); + } + + /// + /// Validates a parameter name from plan XML as a plain @identifier. Simple and + /// forced parameterization name their parameters @0, @1, ..., so a digit may + /// come right after the @. Anything else is dropped from the generated script. /// private static bool IsValidParameterName(string name) { - return Regex.IsMatch(name, @"^@[\p{L}_@#$][\p{L}\p{Nd}_@#$]*$"); + /* \A and \z, not ^ and $: $ also matches before a final line break. */ + return Regex.IsMatch(name, @"\A@[\p{L}\p{Nd}_@#$]+\z"); } /// - /// Validates a parameter data type from plan XML: type name with optional - /// schema prefix, brackets, and (size/precision) suffix. No quotes or comment - /// characters, so it can't disturb the sp_executesql declaration list. + /// Validates a parameter data type from plan XML by its shape: one to three + /// dot-separated names, each plain or in brackets, then an optional (n), (max), + /// (p,s) or (n,name) suffix, as in decimal(18,2), sys.geography or + /// vector(3,float16). A type with anything else, such as text after the closing + /// paren, is dropped with a warning rather than producing a script that fails. + /// Spaces are allowed only inside brackets and around the suffix's parts, and the + /// suffix's numbers use only the digits 0 to 9. No quotes, comment characters or + /// line breaks get through, so it can't disturb the sp_executesql declaration list. /// private static bool IsValidDataType(string dataType) { - return Regex.IsMatch(dataType, @"^[\p{L}\p{Nd}_\[\]., ()]+$"); + const string name = @"(?:\[[\p{L}\p{Nd}_ ]+\]|[\p{L}_][\p{L}\p{Nd}_]*)"; + return Regex.IsMatch( + dataType, + $@"\A{name}(?:\.{name}){{0,2}}(?: *\( *(?:max|[0-9]+(?: *, *(?:[0-9]+|{name}))?) *\))?\z", + RegexOptions.IgnoreCase); } /// @@ -437,16 +506,19 @@ private static bool IsSafeLiteral(string value) if (value.Equals("NULL", StringComparison.OrdinalIgnoreCase)) return true; - /* Integer/decimal/float/money forms: -12, 3.14, 1.5E+3, $9.99 */ - if (Regex.IsMatch(value, @"^-?\$?\d+(\.\d+)?([eE][+-]?\d+)?$")) + /* \A and \z throughout: $ would also accept a value that ends in a line break. */ + + /* Integer/decimal/float/money forms: -12, 3.14, 1.5E+3, $9.99. [0-9], not \d, + which also matches digits from other scripts that T-SQL doesn't read as numbers. */ + if (Regex.IsMatch(value, @"\A-?\$?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?\z")) return true; /* Binary literal */ - if (Regex.IsMatch(value, @"^0x[0-9A-Fa-f]*$")) + if (Regex.IsMatch(value, @"\A0x[0-9A-Fa-f]*\z")) return true; /* One complete string literal — every embedded quote must be doubled */ - if (Regex.IsMatch(value, @"^N?'([^']|'')*'$")) + if (Regex.IsMatch(value, @"\AN?'([^']|'')*'\z")) return true; return false; diff --git a/src/PlanViewer.Core/Services/RowEstimateHelper.cs b/src/PlanViewer.Core/Services/RowEstimateHelper.cs new file mode 100644 index 00000000..0820d04f --- /dev/null +++ b/src/PlanViewer.Core/Services/RowEstimateHelper.cs @@ -0,0 +1,71 @@ +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Services; + +/// +/// Compares an operator's actual row count to its estimate when the estimate is per execution +/// and the actual is a running total — across every execution, or, in a parallel zone, across +/// every thread. Shared by the plan viewer's node label, edge color and minimap (App and Web) +/// and by the analyzer's row-estimate rules, so every one of them agrees on what "the estimate" +/// means for a node that runs more than once (#594). +/// +public static class RowEstimateHelper +{ + /// + /// True when sits anywhere inside the second (inner) input of a + /// Nested Loops join — walking up through every Nested Loops ancestor the same way + /// PlanAnalyzer.Detection.CollectBareOuterReferences does, so a nested-loops-within- + /// loops chain still counts as inner. SQL Server reports a real per-thread loop-iteration + /// count for a node on this side, so its ActualExecutions, summed across threads, is a true + /// total rather than a thread count. + /// + public static bool IsInnerSideOfNestedLoops(PlanNode node) + { + var child = node; + var ancestor = node.Parent; + + while (ancestor != null) + { + if (ancestor.PhysicalOp == "Nested Loops" && + ancestor.Children.Count > 1 && + ancestor.Children[1] == child) + { + return true; + } + + child = ancestor; + ancestor = ancestor.Parent; + } + + return false; + } + + /// + /// EstimateRows is always per execution. ActualRows is the total across every execution — or, + /// in a parallel zone, across every thread. Multiplying EstimateRows by ActualExecutions only + /// turns it into a fair comparison when ActualExecutions is itself a real per-execution count, + /// which holds only for a node on the inner side of a + /// Nested Loops join. Everywhere else, a parallel zone reports one thread record per DOP, and + /// ActualExecutions summed over those threads just counts threads — multiplying by it there + /// would inflate the expected total by DOP instead of comparing like with like. + /// + public static double GetExpectedRows(PlanNode node) + { + return node.ActualExecutions > 0 && IsInnerSideOfNestedLoops(node) + ? node.EstimateRows * node.ActualExecutions + : node.EstimateRows; + } + + /// + /// ActualRows / , with the same zero-estimate handling used + /// throughout the viewer: no expected rows and no actual rows either is a match (1.0); no + /// expected rows but some actual rows is an unbounded miss (double.MaxValue). + /// + public static double GetRowAccuracyRatio(PlanNode node) + { + var expectedRows = GetExpectedRows(node); + return expectedRows > 0 + ? node.ActualRows / expectedRows + : (node.ActualRows > 0 ? double.MaxValue : 1.0); + } +} diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.Helpers.cs b/src/PlanViewer.Core/Services/ShowPlanParser.Helpers.cs index 9bc1978f..0908e4d6 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.Helpers.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.Helpers.cs @@ -25,11 +25,19 @@ internal static string CleanTempTableName(string name) // Skip trailing hex digits (0-9, A-F, a-f) while (i > 0 && IsHexDigit(name[i])) i--; + // Real SQL-internal #temp names always carry underscore padding between the visible + // name and the hex suffix. If there is no underscore right after the hex run, this is + // not that pattern (e.g. a user name that is itself all hex, like "#deadbeef1", or a + // table variable's internal name, like "#A1B2C3D4") — leave it untouched rather than + // stripping it down to "#". Ported from PerformanceMonitor b31e5d18. + if (i == 0 || name[i] != '_') return name; + // Skip trailing underscores (the padding) while (i > 0 && name[i] == '_') i--; - // Only clean if we actually removed a meaningful amount (at least 8 chars of padding+hex) - if (name.Length - i > 8) + // Only clean if we removed a meaningful amount (at least 8 chars of padding+hex) and a + // real name still remains — never collapse to just "#". + if (i > 0 && name.Length - i > 8) return name[..(i + 1)]; return name; @@ -38,14 +46,22 @@ internal static string CleanTempTableName(string name) private static bool IsHexDigit(char c) => (c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f'); - private static IEnumerable ScopedDescendants(XElement element, XName name) + internal static IEnumerable ScopedDescendants(XElement element, XName name) { - foreach (var child in element.Elements()) + /* #589: a loop with its own stack, in document order. The recursive iterator this replaces + used stack for every level of nesting, and no depth guard counts these elements, so deep + nesting inside one operator overflowed even the parser thread's stack. */ + var pending = new Stack(); + foreach (var child in element.Elements().Reverse()) + pending.Push(child); + + while (pending.Count > 0) { - if (child.Name == Ns + "RelOp") continue; - if (child.Name == name) yield return child; - foreach (var desc in ScopedDescendants(child, name)) - yield return desc; + var current = pending.Pop(); + if (current.Name == Ns + "RelOp") continue; + if (current.Name == name) yield return current; + foreach (var child in current.Elements().Reverse()) + pending.Push(child); } } diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs b/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs index 0cb9c4ee..f7ddd421 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.RelOp.cs @@ -160,6 +160,7 @@ private static void ParseOperatorProperties(PlanNode node, XElement relOpEl, XEl var index = objEl.Attribute("Index")?.Value?.Replace("[", "").Replace("]", ""); node.DatabaseName = db; + node.SchemaName = schema; node.IndexName = index; var shortParts = new List(); diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs index b5a778e9..62d09faa 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; -using System.Xml; +using System.Runtime.ExceptionServices; +using System.Runtime.Versioning; using System.Xml.Linq; using PlanViewer.Core.Models; @@ -17,22 +17,39 @@ public static partial class ShowPlanParser // StackOverflowException that takes the whole process down. // Internal so tests can pin behavior just past each limit without hardcoding the values. internal const int MaxParseDepth = 1000; - internal const int MaxParseCharacters = 16 * 1024 * 1024; + internal const int MaxParseCharacters = PlanXml.MaxCharacters; + + /* #589: the tree walk recurses once per nested operator, and its frames are large. On a 1 MB + caller thread (the UI thread, the CLI's main thread) the process died with an uncatchable + StackOverflowException at about 450-480 levels, so the MaxParseDepth guard above could never + fire. The walk now runs on its own thread, with a stack that holds MaxParseDepth levels many + times over, and the guard is what stops a deep plan. Same fix as PerformanceMonitor#4551. */ + private const int ParseThreadStackBytes = 32 * 1024 * 1024; public static ParsedPlan Parse(string xml) { var plan = new ParsedPlan { RawXml = xml }; + XDocument document; + try + { + /* The same limits as ParseAsync: this synchronous path (PlanViewerControl, the web + viewer, the analysis pipeline) once went straight to XDocument.Parse with none. */ + document = PlanXml.Parse(xml); + } + catch (Exception exception) + { + plan.ParseError = exception.Message; + return plan; + } + + /* Blazor WebAssembly cannot start a thread, so the web viewer walks the tree on the + calling thread, as before. */ + if (OperatingSystem.IsBrowser()) + return ParseDocument(document, plan, CancellationToken.None); + try { - /* Same ceiling ParseAsync enforces through XmlReaderSettings.MaxCharactersInDocument, - which this synchronous path (PlanViewerControl, the web viewer, the analysis - pipeline) never had - it went straight to XDocument.Parse with no limit at all. - The input is already an in-memory string here, so a length check is the equivalent - guard; like the reader setting, the limit is in characters, not bytes. */ - if (xml.Length > MaxParseCharacters) - throw new InvalidOperationException( - $"Plan XML exceeds the supported size limit of {MaxParseCharacters.ToString("N0", CultureInfo.InvariantCulture)} characters."); - return ParseDocument(XDocument.Parse(xml), plan, CancellationToken.None); + return RunOnParseThread(() => ParseDocument(document, plan, CancellationToken.None)); } catch (Exception exception) { @@ -49,19 +66,15 @@ internal static async Task ParseAsync( var plan = new ParsedPlan { RawXml = xml }; try { - var settings = new XmlReaderSettings - { - Async = true, - DtdProcessing = DtdProcessing.Prohibit, - MaxCharactersInDocument = MaxParseCharacters, - XmlResolver = null - }; - using var textReader = new StringReader(xml); - using var xmlReader = XmlReader.Create(textReader, settings); - var document = await XDocument - .LoadAsync(xmlReader, LoadOptions.None, cancellationToken) - .ConfigureAwait(false); - return ParseDocument(document, plan, cancellationToken, beforeCostComputation); + var document = await PlanXml.ParseAsync(xml, cancellationToken).ConfigureAwait(false); + + /* #589: after the await this runs on the calling thread or a thread-pool thread, and + neither holds MaxParseDepth levels. The join blocks this thread for as long as the + walk used to run on it, so the thread pool does no more work than before. */ + if (OperatingSystem.IsBrowser()) + return ParseDocument(document, plan, cancellationToken, beforeCostComputation); + return RunOnParseThread( + () => ParseDocument(document, plan, cancellationToken, beforeCostComputation)); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -74,6 +87,39 @@ internal static async Task ParseAsync( } } + /// + /// Runs on a new thread with a stack + /// and waits for it. An exception the walk throws, such as an OperationCanceledException, is + /// rethrown here unchanged. Nothing is left unhandled on the new thread, because an unhandled + /// exception there would end the process. The caller's culture reaches the new thread with the + /// execution context, so parser text such as spill warnings is formatted as before. + /// + [UnsupportedOSPlatform("browser")] + private static ParsedPlan RunOnParseThread(Func walk) + { + ParsedPlan? result = null; + ExceptionDispatchInfo? failure = null; + var thread = new Thread(() => + { + try + { + result = walk(); + } + catch (Exception exception) + { + failure = ExceptionDispatchInfo.Capture(exception); + } + }, ParseThreadStackBytes) + { + IsBackground = true, + Name = "Plan parser" + }; + thread.Start(); + thread.Join(); + failure?.Throw(); + return result!; + } + private static ParsedPlan ParseDocument( XDocument document, ParsedPlan plan, @@ -159,12 +205,21 @@ private static List ParseStatementAndChildren( if (localName == "StmtCond") { - // IF/ELSE blocks — recurse into Condition, Then, Else + /* IF/ELSE blocks. #580: Condition never holds a Stmt* element. Per the XSD + (StmtCondType/Condition) it holds the condition's OWN QueryPlan (0 or 1) plus optional + UDF sub-plans, while that plan's statement-level facts (StatementType "COND WITH QUERY", + the hashes, StatementSetOptions) sit on the StmtCond itself. Recursing into Condition's + children handed the bare QueryPlan to ParseStatement as if it were a statement: an empty + "STATEMENT" placeholder, with the operator tree, hashes, missing indexes and warnings + dropped. So the StmtCond is parsed as the statement, and Condition is where its plan and + sub-plans are read from. A plain IF with neither stays out of the list, as before. */ var condEl = stmtEl.Element(Ns + "Condition"); - if (condEl != null) + if (condEl != null + && (condEl.Element(Ns + "QueryPlan") != null || condEl.Element(Ns + "UDF") != null)) { - foreach (var child in condEl.Elements()) - results.AddRange(ParseStatementAndChildren(child, depth + 1, cancellationToken)); + var condStmt = ParseStatement(stmtEl, depth, cancellationToken, planContainerEl: condEl); + if (condStmt != null) + results.Add(condStmt); } var thenStmts = stmtEl.Element(Ns + "Then")?.Element(Ns + "Statements"); @@ -250,11 +305,14 @@ depth silently reset to zero at every procedure boundary and the MaxParseDepth g ParseStatementAndChildren could never fire across StoredProc/UDF nesting - a crafted plan alternating StmtSimple > StoredProc > Statements a few thousand levels deep (about sixty bytes each) still reached the uncatchable StackOverflowException the guard exists to - prevent. Carrying the caller's depth through this method closes that reset. */ + prevent. Carrying the caller's depth through this method closes that reset. + planContainerEl is where the QueryPlan and the UDF/StoredProc sub-plans are read from when + that is not the statement element itself: a StmtCond keeps them under Condition (#580). */ private static PlanStatement? ParseStatement( XElement stmtEl, int depth = 0, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + XElement? planContainerEl = null) { cancellationToken.ThrowIfCancellationRequested(); var stmt = new PlanStatement @@ -269,7 +327,8 @@ prevent. Carrying the caller's depth through this method closes that reset. */ if (stmtEl.Name.LocalName == "StmtUseDb") stmt.StmtUseDatabaseName = stmtEl.Attribute("Database")?.Value; - var queryPlanEl = stmtEl.Element(Ns + "QueryPlan"); + var containerEl = planContainerEl ?? stmtEl; + var queryPlanEl = containerEl.Element(Ns + "QueryPlan"); // XSD gap: Dispatcher/PSP (on StmtSimple, not inside QueryPlan) var dispatcherEl = stmtEl.Element(Ns + "Dispatcher"); @@ -314,10 +373,15 @@ prevent. Carrying the caller's depth through this method closes that reset. */ so it took that early return and never reached this code, seventy lines further down. The parser looked like it descended into procedures and in the one case that matters never did. The same was true of a UDF call whose statement carries no plan of its own. */ - ParseSubPlans(stmt, stmtEl, depth, cancellationToken); + ParseSubPlans(stmt, containerEl, depth, cancellationToken); if (queryPlanEl == null) { + /* #580: the statement attributes (QueryHash, QueryPlanHash, StatementId and the rest) + sit on the statement element and never depended on a QueryPlan child, but they were + read only after this return, so a MULTIPLE PLAN statement lost the hashes it carries. */ + ParseStmtAttributes(stmt, stmtEl); + // Statements with no QueryPlan (e.g., DECLARE/ASSIGN) still get a synthetic // root node so they appear in the statement tab list. var stmtType = stmt.StatementType.Length > 0 @@ -554,6 +618,8 @@ private static void ParseQueryPlanElements(PlanStatement stmt, XElement stmtEl, RequestedMemoryKB = ParseLong(memEl.Attribute("RequestedMemory")?.Value), GrantedMemoryKB = ParseLong(memEl.Attribute("GrantedMemory")?.Value), MaxUsedMemoryKB = ParseLong(memEl.Attribute("MaxUsedMemory")?.Value), + HasMaxUsedMemory = long.TryParse(memEl.Attribute("MaxUsedMemory")?.Value, + System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out _), GrantWaitTimeMs = ParseLong(memEl.Attribute("GrantWaitTime")?.Value), LastRequestedMemoryKB = ParseLong(memEl.Attribute("LastRequestedMemory")?.Value), IsMemoryGrantFeedbackAdjusted = memEl.Attribute("IsMemoryGrantFeedbackAdjusted")?.Value diff --git a/src/PlanViewer.Core/Services/TimeDisplayHelper.cs b/src/PlanViewer.Core/Services/TimeDisplayHelper.cs index b5dd608b..b016a98b 100644 --- a/src/PlanViewer.Core/Services/TimeDisplayHelper.cs +++ b/src/PlanViewer.Core/Services/TimeDisplayHelper.cs @@ -9,30 +9,53 @@ public enum TimeDisplayMode Server } -public static class TimeDisplayHelper +/// +/// One connection's offset in minutes from UTC to its server's local time (E5). +/// +/// Each connect makes a new one, and every document opened on that connection keeps it. That +/// is the point of it being an object rather than a number: the offset is fetched a moment after +/// the connect lands, and the documents that read it must be reading the connection they got their +/// data from, not whichever connection the process touched last. It used to be a process-wide +/// static, so two sessions on servers in different time zones — or one session that reconnected — +/// shifted each other's Query Store times by the wrong server's offset. +/// +/// Zero until the fetch lands, and zero if it fails, which reads as UTC in Server mode. +/// Readers take the value each time they format a time, so a document opened before the fetch +/// finished picks the real offset up the next time it redraws. +/// +public sealed class ServerUtcOffset { - public static TimeDisplayMode Current { get; set; } = TimeDisplayMode.Local; + public int Minutes { get; set; } +} +public static class TimeDisplayHelper +{ /// - /// Offset in minutes from UTC to the connected SQL Server's local time. - /// Set after connecting to a server. + /// The user's display preference. Global on purpose: it is one setting, not something a + /// connection owns. The offset Server mode needs is not global, and is passed in by whoever + /// is formatting the time (see ). /// - public static int ServerUtcOffsetMinutes { get; set; } + public static TimeDisplayMode Current { get; set; } = TimeDisplayMode.Local; + + public static DateTime ConvertForDisplay(DateTime utcTime, int serverUtcOffsetMinutes) + { + return ConvertForDisplay(utcTime, Current, serverUtcOffsetMinutes); + } - public static DateTime ConvertForDisplay(DateTime utcTime) + public static DateTime ConvertForDisplay(DateTime utcTime, TimeDisplayMode mode, int serverUtcOffsetMinutes) { - return Current switch + return mode switch { TimeDisplayMode.Local => utcTime.ToLocalTime(), TimeDisplayMode.Utc => DateTime.SpecifyKind(utcTime, DateTimeKind.Utc), - TimeDisplayMode.Server => utcTime.AddMinutes(ServerUtcOffsetMinutes), + TimeDisplayMode.Server => utcTime.AddMinutes(serverUtcOffsetMinutes), _ => utcTime.ToLocalTime() }; } - public static string FormatForDisplay(DateTime utcTime, string format = "yyyy-MM-dd HH:mm") + public static string FormatForDisplay(DateTime utcTime, int serverUtcOffsetMinutes, string format = "yyyy-MM-dd HH:mm") { - return ConvertForDisplay(utcTime).ToString(format); + return ConvertForDisplay(utcTime, serverUtcOffsetMinutes).ToString(format); } public static string Suffix => Current switch diff --git a/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj b/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj index 3cf66e1e..c14f793c 100644 --- a/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj +++ b/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj @@ -8,4 +8,9 @@ app.manifest + + + + + diff --git a/src/PlanViewer.Ssms.Installer/Program.cs b/src/PlanViewer.Ssms.Installer/Program.cs index f0ca5352..1a23719c 100644 --- a/src/PlanViewer.Ssms.Installer/Program.cs +++ b/src/PlanViewer.Ssms.Installer/Program.cs @@ -1,7 +1,13 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.IO.Compression; +using System.IO.Packaging; using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; namespace PlanViewer.Ssms.Installer { @@ -13,8 +19,23 @@ static readonly (string Label, string VsixInstallerPath)[] SsmsVersions = ("SSMS 21", @"C:\Program Files\Microsoft SQL Server Management Studio 21\Common7\IDE\VSIXInstaller.exe"), }; + // Relationship types and content type of the OPC package signature. + const string SignatureRelationshipPrefix = "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/"; + const string OriginRelationship = SignatureRelationshipPrefix + "origin"; + const string CertificateRelationship = SignatureRelationshipPrefix + "certificate"; + const string CertificateContentType = "application/vnd.openxmlformats-package.digital-signature-certificate"; + + // The one ZIP entry that OPC does not treat as a part. + const string ContentTypesEntry = "[Content_Types].xml"; + + // The source of the relationships of the package itself. + static readonly Uri PackageRoot = new Uri("/", UriKind.Relative); + static int Main(string[] args) { + if (args.Length > 0 && string.Equals(args[0], "--verify-only", StringComparison.OrdinalIgnoreCase)) + return VerifyOnly(args.Length > 1 ? args[1] : null); + Console.WriteLine("==========================================="); Console.WriteLine(" Performance Studio — SSMS Extension"); Console.WriteLine("==========================================="); @@ -41,39 +62,87 @@ static int Main(string[] args) return 1; } + string tempDir = null; bool anyFailed = false; - foreach (var (label, installerPath) in installed) + try { - Console.WriteLine($"Found {label} — installing..."); - - var psi = new ProcessStartInfo + var installerCert = GetSigner(Assembly.GetExecutingAssembly().Location); + if (installerCert == null) + { + Console.WriteLine("This installer is not signed, so the VSIX signature is not checked."); + Console.WriteLine(); + } + else { - FileName = installerPath, - Arguments = $"/admin \"{vsixPath}\"", - UseShellExecute = false, - }; + // A signed installer installs only a VSIX signed with the same certificate. + // The check runs on a private copy, and that copy is the file that gets installed. + string error; + try + { + tempDir = Path.Combine(Path.GetTempPath(), "PlanViewerSsms-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + var copy = Path.Combine(tempDir, Path.GetFileName(vsixPath)); + File.Copy(vsixPath, copy); + vsixPath = copy; + error = CheckVsix(copy, installerCert); + } + catch (Exception ex) + { + error = $"the installer failed to copy the file for the check ({ex.Message})"; + } - try + if (error != null) + { + DeleteFolder(tempDir); + Console.WriteLine($"ERROR: {Path.GetFileName(vsixPath)} failed the signature check: {error}."); + Console.WriteLine("Nothing was installed."); + Console.WriteLine("Download InstallSsmsExtension.exe and PlanViewer.Ssms.vsix again from the same release, and keep them in one folder."); + Console.WriteLine("You can also double-click PlanViewer.Ssms.vsix to install it."); + WaitForKey(); + return 1; + } + + Console.WriteLine("The VSIX is signed with the same certificate as this installer."); + Console.WriteLine(); + } + + foreach (var (label, installerPath) in installed) { - var proc = Process.Start(psi); - proc.WaitForExit(); + Console.WriteLine($"Found {label} — installing..."); - if (proc.ExitCode == 0) + var psi = new ProcessStartInfo { - Console.WriteLine($" OK — installed into {label}. Restart SSMS to activate."); + FileName = installerPath, + Arguments = $"/admin \"{vsixPath}\"", + UseShellExecute = false, + }; + + try + { + var proc = Process.Start(psi); + proc.WaitForExit(); + + if (proc.ExitCode == 0) + { + Console.WriteLine($" OK — installed into {label}. Restart SSMS to activate."); + } + else + { + Console.WriteLine($" FAILED (exit code {proc.ExitCode})."); + anyFailed = true; + } } - else + catch (Exception ex) { - Console.WriteLine($" FAILED (exit code {proc.ExitCode})."); + Console.WriteLine($" FAILED: {ex.Message}"); anyFailed = true; } + Console.WriteLine(); } - catch (Exception ex) - { - Console.WriteLine($" FAILED: {ex.Message}"); - anyFailed = true; - } - Console.WriteLine(); + } + finally + { + DeleteFolder(tempDir); } if (anyFailed) @@ -88,6 +157,234 @@ static int Main(string[] args) return 0; } + // Checks the VSIX against this installer's certificate without installing anything. + static int VerifyOnly(string vsixPath) + { + if (vsixPath == null || !File.Exists(vsixPath)) + { + Console.WriteLine("ERROR: --verify-only needs the path of a .vsix file."); + return 1; + } + + var installerCert = GetSigner(Assembly.GetExecutingAssembly().Location); + if (installerCert == null) + { + Console.WriteLine("This installer is not signed, so the VSIX signature is not checked."); + return 0; + } + + var error = CheckVsix(vsixPath, installerCert); + if (error != null) + { + Console.WriteLine($"ERROR: {Path.GetFileName(vsixPath)} failed the signature check: {error}."); + return 1; + } + + Console.WriteLine("The VSIX is signed with the same certificate as this installer."); + return 0; + } + + // The Authenticode certificate of the installer, or null when the installer is not signed. + // This reads the certificate only. It does not validate the signature. + internal static X509Certificate GetSigner(string exePath) + { + try { return X509Certificate.CreateFromSignedFile(exePath); } + catch (CryptographicException) { return null; } + } + + // Returns null when the VSIX passes, or a short reason when it does not. The VSIX passes when it has + // exactly one valid signature, made with installerCert, and the signature covers every entry in the ZIP file. + internal static string CheckVsix(string vsixPath, X509Certificate installerCert) + { + try + { + // One read of the file feeds both views of it: the OPC package and the raw ZIP entries. + var file = File.ReadAllBytes(vsixPath); + using (var package = Package.Open(new MemoryStream(file), FileMode.Open, FileAccess.Read)) + { + var manager = new PackageDigitalSignatureManager(package); + if (manager.Signatures.Count == 0) + return "the file is not signed"; + if (manager.Signatures.Count > 1) + return "the file has more than one signature"; + + // Compare the certificate first, so that a file from another signer is rejected before the + // signature is verified. The whole certificate must match, not only its thumbprint. + // The verification below uses this same Signer. A signature that holds no certificate has + // nothing to compare, and VerifySignatures reports it as CertificateRequired. + var signature = manager.Signatures[0]; + var certificate = installerCert.GetRawCertData(); + if (signature.Signer == null) + return $"the signature is not valid ({VerifyResult.CertificateRequired})"; + if (!signature.Signer.GetRawCertData().SequenceEqual(certificate)) + return "the file is signed with a different certificate than this installer"; + + var result = manager.VerifySignatures(false); + if (result != VerifyResult.Success) + return $"the signature is not valid ({result})"; + + var uncovered = FindUncovered(file, package, manager, signature, certificate); + if (uncovered.Count > 0) + return "the signature does not cover " + string.Join(", ", uncovered.Take(3)) + (uncovered.Count > 3 ? $" and {uncovered.Count - 3} more" : ""); + + return null; + } + } + catch (Exception ex) + { + return $"the installer failed to read the file ({ex.Message})"; + } + } + + // Lists what the signature does not cover. The list starts from the raw ZIP entries, not from the parts that + // OPC finds. Names are compared exactly, so names that differ only in case are different names. Every entry + // must be one of these: + // - a part that the signature signs; + // - [Content_Types].xml; + // - a relationship part that the signature covers, or that belongs to the origin part or the signature part; + // - the origin part, when it is signed or empty; + // - the signature part; + // - a certificate part that holds exactly the certificate of the installer. + // A relationship part is covered when it is signed whole, or when the signature selects every relationship + // in it. The origin relationship of the package is the one exception: some signers add it after signing, so + // it needs no cover. The relationships of the origin part and the signature part may point only to entries + // in this list. An entry is never classified by parsing what is in it. The only reads are the checks that the + // origin part is empty and that a certificate part is the certificate of the installer. + static List FindUncovered(byte[] file, Package package, PackageDigitalSignatureManager manager, PackageDigitalSignature signature, byte[] certificate) + { + var signedNames = new HashSet(signature.SignedParts.Select(EntryName), StringComparer.Ordinal); + var originName = EntryName(manager.SignatureOrigin); + var signatureName = EntryName(signature.SignaturePart.Uri); + + // A certificate part is the target of a certificate relationship from the signature part and has the certificate content type. + var certificateNames = new HashSet(StringComparer.Ordinal); + foreach (var rel in signature.SignaturePart.GetRelationshipsByType(CertificateRelationship)) + { + if (rel.TargetMode != TargetMode.Internal) + continue; + var target = PackUriHelper.ResolvePartUri(signature.SignaturePart.Uri, rel.TargetUri); + if (package.PartExists(target) && string.Equals(package.GetPart(target).ContentType, CertificateContentType, StringComparison.OrdinalIgnoreCase)) + certificateNames.Add(EntryName(target)); + } + + // Relationship parts. Signers differ in how they cover them: some sign the whole part, some select + // single relationships. + var allowed = new HashSet(signedNames, StringComparer.Ordinal) { ContentTypesEntry, signatureName }; + var selected = new HashSet(signature.SignedRelationshipSelectors.SelectMany(s => s.Select(package)).Select(RelationshipKey)); + var sources = new List<(Uri Uri, IEnumerable Relationships)> { (PackageRoot, package.GetRelationships()) }; + foreach (var part in package.GetParts().Where(p => !PackUriHelper.IsRelationshipPartUri(p.Uri))) + sources.Add((part.Uri, part.GetRelationships())); + + var relationshipFindings = new List(); + var explained = new HashSet(StringComparer.Ordinal); + foreach (var source in sources) + { + var sourceName = EntryName(source.Uri); + var relationshipsName = EntryName(PackUriHelper.GetRelationshipPartUri(source.Uri)); + if (sourceName == originName || sourceName == signatureName) + { + // These two relationship parts are unsigned. Where their relationships point is checked below. + allowed.Add(relationshipsName); + continue; + } + + if (signedNames.Contains(relationshipsName)) + continue; + + var covered = true; + foreach (var rel in source.Relationships) + { + var isOrigin = sourceName.Length == 0 && rel.RelationshipType == OriginRelationship && rel.TargetMode == TargetMode.Internal + && EntryName(PackUriHelper.ResolvePartUri(source.Uri, rel.TargetUri)) == originName; + if (isOrigin || selected.Contains(RelationshipKey(rel))) + continue; + relationshipFindings.Add($"relationship {rel.Id} of {rel.SourceUri}"); + covered = false; + } + + if (covered) + allowed.Add(relationshipsName); + else + explained.Add(relationshipsName); + } + + var uncovered = new List(); + var accepted = new HashSet(StringComparer.Ordinal); + using (var zip = new ZipArchive(new MemoryStream(file), ZipArchiveMode.Read)) + { + // Names that differ only in case are one file when the VSIX is unpacked on Windows. + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var entry in zip.Entries) + { + var name = entry.FullName; + if (!seen.Add(name)) + uncovered.Add(Display(name) + " (a second entry with the same name)"); + else if (allowed.Contains(name) + || (name == originName && HasContent(entry, new byte[0])) + || (certificateNames.Contains(name) && HasContent(entry, certificate))) + accepted.Add(name); + else if (!explained.Contains(name)) + uncovered.Add(Display(name)); + } + } + + uncovered.Sort(StringComparer.Ordinal); + uncovered.AddRange(relationshipFindings); + + foreach (var partUri in new[] { manager.SignatureOrigin, signature.SignaturePart.Uri }) + { + if (!package.PartExists(partUri)) + continue; + foreach (var rel in package.GetPart(partUri).GetRelationships()) + { + if (rel.TargetMode != TargetMode.Internal || !accepted.Contains(EntryName(PackUriHelper.ResolvePartUri(rel.SourceUri, rel.TargetUri)))) + uncovered.Add($"relationship {rel.Id} of {rel.SourceUri}"); + } + } + + return uncovered; + } + + // The name of the ZIP entry that holds a part: the part name without its leading slash. + // The package itself has the name "". + static string EntryName(Uri partUri) + { + var name = partUri.OriginalString; + if (!name.StartsWith("/", StringComparison.Ordinal)) + throw new InvalidDataException($"the part name {name} is not valid"); + return name.Substring(1); + } + + static string RelationshipKey(PackageRelationship rel) => rel.SourceUri + " " + rel.Id; + + // True when the entry holds exactly these bytes. It reads no more than one byte past the expected length. + static bool HasContent(ZipArchiveEntry entry, byte[] expected) + { + using (var stream = entry.Open()) + { + var actual = new byte[expected.Length + 1]; + var length = 0; + int read; + while (length < actual.Length && (read = stream.Read(actual, length, actual.Length - length)) > 0) + length += read; + return length == expected.Length && actual.Take(length).SequenceEqual(expected); + } + } + + // An entry name as it appears in a message, like a part name. Control characters and long names are cut down. + static string Display(string entryName) + { + var shown = new string(entryName.Select(c => char.IsControl(c) ? '?' : c).ToArray()); + return "/" + (shown.Length > 80 ? shown.Substring(0, 80) + "..." : shown); + } + + static void DeleteFolder(string path) + { + if (path == null) + return; + try { Directory.Delete(path, true); } catch { } + } + static string FindVsix(string[] args) { // 1. Explicit argument @@ -100,13 +397,16 @@ static string FindVsix(string[] args) if (File.Exists(candidate)) return candidate; - // 3. Look in common build output locations relative to exe +#if DEBUG + // 3. Dev builds only: look in common build output locations relative + // to exe. A release build uses only the argument or the exe's folder. foreach (var sub in new[] { ".", @"..\bin\Release", @"..\bin\Debug" }) { candidate = Path.GetFullPath(Path.Combine(exeDir, sub, "PlanViewer.Ssms.vsix")); if (File.Exists(candidate)) return candidate; } +#endif return null; } diff --git a/src/PlanViewer.Ssms/AppLauncher.cs b/src/PlanViewer.Ssms/AppLauncher.cs index f48c71df..ebe2b749 100644 --- a/src/PlanViewer.Ssms/AppLauncher.cs +++ b/src/PlanViewer.Ssms/AppLauncher.cs @@ -2,6 +2,7 @@ using System.Diagnostics; using System.IO; using System.IO.Pipes; +using System.Security.Principal; using Microsoft.Win32; namespace PlanViewer.Ssms @@ -113,6 +114,12 @@ private static bool TrySendToRunningInstance(string filePath) using (var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out)) { client.Connect(1000); // 1 second timeout + + // A pipe of this name that another account created first must not get + // the path. Returning false launches the app instead. + if (!IsOwnedByCurrentUser(client)) + return false; + using (var writer = new StreamWriter(client)) { writer.WriteLine(filePath); @@ -128,6 +135,24 @@ private static bool TrySendToRunningInstance(string filePath) } } + /// + /// True when the pipe's owner is this user. The app creates its pipe with .NET's + /// CurrentUserOnly option, which makes the owner the creating token's owner: the + /// user, or the Administrators group when the app runs elevated. So the owner is + /// compared with this token's user and with its owner, and an elevated SSMS still + /// reaches an app that is not elevated. .NET Framework has no CurrentUserOnly + /// option on the client, so this is the same check done by hand. + /// + private static bool IsOwnedByCurrentUser(NamedPipeClientStream client) + { + var owner = client.GetAccessControl().GetOwner(typeof(SecurityIdentifier)); + using (var identity = WindowsIdentity.GetCurrent()) + { + return owner != null + && (owner.Equals(identity.User) || owner.Equals(identity.Owner)); + } + } + private static string FindApp() { // Order matters. PATH is searched last because any writable directory diff --git a/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj b/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj index 36d13890..5d65d330 100644 --- a/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj +++ b/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj @@ -65,8 +65,13 @@ true - - LICENSE + + + LICENSE.txt true PreserveNewest @@ -75,9 +80,9 @@ - - - + + + @@ -87,4 +92,7 @@ + + + diff --git a/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs b/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs index f0fefa7a..a1cdbcaf 100644 --- a/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs +++ b/src/PlanViewer.Ssms/Properties/AssemblyInfo.cs @@ -7,5 +7,5 @@ [assembly: AssemblyProduct("Performance Studio for SSMS")] [assembly: AssemblyCopyright("Copyright Darling Data 2026")] [assembly: ComVisible(false)] -[assembly: AssemblyVersion("1.27.0.0")] -[assembly: AssemblyFileVersion("1.27.0.0")] +[assembly: AssemblyVersion("1.28.0.0")] +[assembly: AssemblyFileVersion("1.28.0.0")] diff --git a/src/PlanViewer.Ssms/source.extension.vsixmanifest b/src/PlanViewer.Ssms/source.extension.vsixmanifest index c1d98b24..d545edb9 100644 --- a/src/PlanViewer.Ssms/source.extension.vsixmanifest +++ b/src/PlanViewer.Ssms/source.extension.vsixmanifest @@ -3,13 +3,13 @@ xmlns:d="http://schemas.microsoft.com/developer/vsx-schema-design/2011"> Performance Studio for SSMS Adds "Open in Performance Studio" to the execution plan right-click menu in SSMS. Extracts the plan XML and opens it in Performance Studio for advanced analysis. Compatible with SSMS 21 and SSMS 22. https://github.com/erikdarlingdata/PerformanceStudio - LICENSE + LICENSE.txt Resources\PerformanceStudioIcon.png SQL Server, Execution Plan, Performance, SSMS diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index f4878cdd..db6eafb8 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -76,7 +76,16 @@ else