From a115bf423cc3149dd421089724c0ca86cf9adfd3 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:38:34 -0400 Subject: [PATCH 01/85] Say the SignPath policy file is not enforced SignPath's Pipeline Connector (action v3) reads .signpath/policies files only when the signing policy references them as a Pipeline Policy. This SignPath organization is on the OSS subscription, and its dashboard has no Pipeline Policy setting (checked 2026-09-25), so nothing references the file and SignPath does not check the runner rule. The old comment told readers to add the reference in the dashboard, which is not possible. Comments only. Part of #569. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011ujjGZ64tzhVqkEBfaxe4o --- .../PerformanceStudio/release-signing.yml | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) 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: From dc6725859607df2839b29285ae0e7f8da61fdcf5 Mon Sep 17 00:00:00 2001 From: Neal Mummau Date: Fri, 25 Sep 2026 23:58:16 -0400 Subject: [PATCH 02/85] build(deps): centralize NuGet package versions Move all 34 package versions into Directory.Packages.props, preserving existing versions and project-specific metadata. Scope the framework reference entry to SSMS to avoid conflicting with the installer's implicit SDK reference. Update CI cache inputs and deployment triggers for the central file. --- .github/workflows/ci.yml | 5 ++- .github/workflows/deploy-planshare.yml | 1 + .github/workflows/deploy-web.yml | 1 + Directory.Packages.props | 45 +++++++++++++++++++ server/PlanShare/PlanShare.csproj | 4 +- src/PlanViewer.App/PlanViewer.App.csproj | 32 ++++++------- src/PlanViewer.Cli/PlanViewer.Cli.csproj | 6 +-- src/PlanViewer.Core/PlanViewer.Core.csproj | 8 ++-- src/PlanViewer.Ssms/PlanViewer.Ssms.csproj | 6 +-- src/PlanViewer.Web/PlanViewer.Web.csproj | 4 +- .../PlanViewer.Core.Tests.csproj | 12 ++--- 11 files changed, 87 insertions(+), 37 deletions(-) create mode 100644 Directory.Packages.props diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2b044f6..24958cb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,7 @@ jobs: - 'src/PlanViewer.Core/**' - 'src/PlanViewer.Web/**' - 'src/Directory.Build.props' + - 'Directory.Packages.props' - 'tests/**' - 'PlanViewer.sln' - 'global.json' @@ -50,7 +51,9 @@ jobs: 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/deploy-planshare.yml b/.github/workflows/deploy-planshare.yml index 9cbcd931..9f7a93db 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: diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 9e0f8790..1426a704 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: diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000..4279fae6 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,45 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/PlanShare/PlanShare.csproj b/server/PlanShare/PlanShare.csproj index 0d1f7756..f4859515 100644 --- a/server/PlanShare/PlanShare.csproj +++ b/server/PlanShare/PlanShare.csproj @@ -7,7 +7,7 @@ - + - + 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.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.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.Ssms/PlanViewer.Ssms.csproj b/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj index 36d13890..69775c79 100644 --- a/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj +++ b/src/PlanViewer.Ssms/PlanViewer.Ssms.csproj @@ -75,9 +75,9 @@ - - - + + + diff --git a/src/PlanViewer.Web/PlanViewer.Web.csproj b/src/PlanViewer.Web/PlanViewer.Web.csproj index 50c52ed0..47b24b15 100644 --- a/src/PlanViewer.Web/PlanViewer.Web.csproj +++ b/src/PlanViewer.Web/PlanViewer.Web.csproj @@ -9,8 +9,8 @@ - - + + diff --git a/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj index ea2973a7..cbc0e160 100644 --- a/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj +++ b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj @@ -32,12 +32,12 @@ - - - - - - + + + + + + From 59869762a747edfb97eff6d18466b5c58e56aa5c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:20:40 -0400 Subject: [PATCH 03/85] Parse IF-condition query plans and MULTIPLE PLAN hashes (#580) A StmtCond keeps its condition's own QueryPlan (and any UDF sub-plans) under . The parser fed each child of back in as a statement, so the condition became an empty STATEMENT placeholder and its operator tree, hashes, missing indexes and warnings were lost. Parse the StmtCond itself as the statement and read its plan and sub-plans from (new optional planContainerEl argument on ParseStatement). ParseStatement read the statement attributes only after the no-QueryPlan return, so a MULTIPLE PLAN statement lost the QueryHash and QueryPlanHash it carries. Read them before that return. Port of erikdarlingdata/PerformanceMonitor#4470. The two golden baselines change because eager_table_spool_plan.sqlplan has a WHILE (SELECT ...) condition that is now a real statement. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Services/ShowPlanParser.cs | 34 +++- .../ComparisonBaseline.txt | 20 +-- .../ShowPlanParserCondAndMultiplePlanTests.cs | 149 ++++++++++++++++++ .../PlanViewer.Core.Tests/WarningBaseline.txt | 1 + 4 files changed, 187 insertions(+), 17 deletions(-) create mode 100644 tests/PlanViewer.Core.Tests/ShowPlanParserCondAndMultiplePlanTests.cs diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs index b5a778e9..b2947539 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs @@ -159,12 +159,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 +259,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 +281,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 +327,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 diff --git a/tests/PlanViewer.Core.Tests/ComparisonBaseline.txt b/tests/PlanViewer.Core.Tests/ComparisonBaseline.txt index ab8390a1..6b00e4f6 100644 --- a/tests/PlanViewer.Core.Tests/ComparisonBaseline.txt +++ b/tests/PlanViewer.Core.Tests/ComparisonBaseline.txt @@ -150,27 +150,29 @@ Plan A: eager_table_spool_plan.sqlplan Plan B: eager_table_spool_plan.sqlplan --- Statement 1 --- +WHILE ( SELECT dm.[avg_fragmentation_in_percent] FROM sys.indexes i INNER JOIN sys.dm_db_index_physical_stats (DB_ID(), NULL, NULL, NULL, 'LIMITED') dm ON i.[object_id] = dm.[object_id] AND i.[index_id] = dm.[index_id] WHERE OBJECT_NAME(dm.[object_id]) = 'RealBigTest' AND dm.[alloc_unit_type_desc] = 'IN_ROW_DATA' AND i.index_id = 1 ) < 99.0 + + Estimated cost: 0.0099 -> 0.0099 (0.0% costlier) + Estimated rows: 1 -> 1 (0.0% more) + +--- Statement 2 --- BEGIN SELECT TOP ( 5000 ) @bmax = [Sequence] FROM dbo.RealBigTest WITH ( NOLOCK ) WHERE [Sequence] > @hkey ORDER BY Sequence Estimated cost: 0.0217 -> 0.0217 (0.0% costlier) Estimated rows: 5,000 -> 5,000 (0.0% more) ---- Statement 2 --- +--- Statement 3 --- ; UPDATE R SET ID = NEWID() FROM dbo.RealBigTest R WHERE R.[Sequence] > @hkey AND R.[Sequence] <= @bmax Estimated cost: 135,408 -> 135,408 (0.0% costlier) Estimated rows: 12,323,800 -> 12,323,800 (0.0% more) ---- Statement 3 --- -DECLARE @hkey BIGINT = -1 , @bmax BIGINT, @cur VARCHAR(20) - - --- Statement 4 --- -SET NOCOUNT ON +DECLARE @hkey BIGINT = -1 , @bmax BIGINT, @cur VARCHAR(20) --- Statement 5 --- - +SET NOCOUNT ON --- Statement 6 --- @@ -1484,7 +1486,7 @@ SET NOCOUNT ON (no comparison available) --- Statement 3 (only in Plan B) --- - +WHILE ( SELECT dm.[avg_fragmentation_in_percent] FROM sys.indexes i INNER JOIN sys.dm_db_index_physical_stats (DB_ID(), NULL, NULL, NULL, 'LIMITED') dm ON i.[object_id] = dm.[object_id] AND i.[index_id] = dm.[index_id] WHERE OBJECT_NAME(dm.[object_id]) = 'RealBigTest' AND dm.[alloc_unit_type_desc] = 'IN_ROW_DATA' AND i.index_id = 1 ) < 99.0 (no comparison available) --- Statement 4 (only in Plan B) --- @@ -1543,7 +1545,7 @@ SET NOCOUNT ON (no comparison available) --- Statement 3 (only in Plan A) --- - +WHILE ( SELECT dm.[avg_fragmentation_in_percent] FROM sys.indexes i INNER JOIN sys.dm_db_index_physical_stats (DB_ID(), NULL, NULL, NULL, 'LIMITED') dm ON i.[object_id] = dm.[object_id] AND i.[index_id] = dm.[index_id] WHERE OBJECT_NAME(dm.[object_id]) = 'RealBigTest' AND dm.[alloc_unit_type_desc] = 'IN_ROW_DATA' AND i.index_id = 1 ) < 99.0 (no comparison available) --- Statement 4 (only in Plan A) --- diff --git a/tests/PlanViewer.Core.Tests/ShowPlanParserCondAndMultiplePlanTests.cs b/tests/PlanViewer.Core.Tests/ShowPlanParserCondAndMultiplePlanTests.cs new file mode 100644 index 00000000..f659cf7e --- /dev/null +++ b/tests/PlanViewer.Core.Tests/ShowPlanParserCondAndMultiplePlanTests.cs @@ -0,0 +1,149 @@ +using System.Linq; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +/// +/// #580: the parser dropped two statement shapes that carry their own plan or hashes. A +/// StmtCond (IF EXISTS (...)) keeps its condition's own QueryPlan under +/// Condition, and the old code fed that bare QueryPlan to ParseStatement as if it +/// were a statement: an empty STATEMENT placeholder with no operators, no hashes and no missing +/// index. A StmtSimple with StatementType="MULTIPLE PLAN" carries QueryHash and +/// QueryPlanHash but no QueryPlan, and the hashes were read only after the no-plan return. +/// +/// The same two gaps were fixed in PerformanceMonitor's copy of this parser +/// (erikdarlingdata/PerformanceMonitor#4470); the first five tests and the repro XML are ported from +/// there. The last two are Studio's: a UDF sub-plan under Condition attaches to the condition's +/// statement (Studio hangs module bodies off their calling statement), and a plain IF with no +/// query of its own still adds no statement. +/// +public sealed class ShowPlanParserCondAndMultiplePlanTests +{ + private const string TableScanRelOp = """"""; + + private const string ReproStatements = $""" + + + + {TableScanRelOp} + + + + + """; + + private static string Wrap(string statements) => $""" + + {statements} + + """; + + private static ParsedPlan ParseAndAnalyze(string statements) + { + var plan = ShowPlanParser.Parse(Wrap(statements)); + PlanAnalyzer.Analyze(plan); + return plan; + } + + private static PlanStatement Single(ParsedPlan plan, string statementType) => + plan.Batches.SelectMany(b => b.Statements).Single(s => s.StatementType == statementType); + + [Fact] + public void StatementCount_IsThree_OneConditionOneThenOneMultiplePlan() + { + // The condition's own plan (1) + the Then branch's RETURN (1) + the MULTIPLE PLAN sibling (1). + // Condition never nests a Stmt* element, so it contributes exactly one statement. + var plan = ParseAndAnalyze(ReproStatements); + Assert.Equal(3, plan.Batches.SelectMany(b => b.Statements).Count()); + } + + [Fact] + public void CondWithQuery_KeepsItsOwnTextHashesAndRealOperatorRoot() + { + var stmt = Single(ParseAndAnalyze(ReproStatements), "COND WITH QUERY"); + + Assert.Equal("IF EXISTS (SELECT 1 FROM dbo.t WHERE id = @id)", stmt.StatementText); + Assert.Equal("0x1111111111111111", stmt.QueryHash); + Assert.Equal("0x2222222222222222", stmt.QueryPlanHash); + + // The statement-type wrapper holds the real Table Scan, not a bare STATEMENT placeholder. + Assert.NotNull(stmt.RootNode); + var operatorChild = Assert.Single(stmt.RootNode!.Children); + Assert.Equal("Table Scan", operatorChild.PhysicalOp); + } + + [Fact] + public void CondWithQuery_KeepsItsMissingIndexSuggestion() + { + var plan = ParseAndAnalyze(ReproStatements); + var stmt = Single(plan, "COND WITH QUERY"); + + var mi = Assert.Single(stmt.MissingIndexes); + Assert.Equal("dbo", mi.Schema); + Assert.Equal("t", mi.Table); + Assert.Equal(90.5, mi.Impact); + Assert.Equal("id", Assert.Single(mi.EqualityColumns)); + + // It also surfaces through the plan-wide rollup, not only on the statement. + Assert.Single(plan.AllMissingIndexes); + } + + [Fact] + public void ThenBranch_StillHasItsReturnStatement() + { + var stmt = Single(ParseAndAnalyze(ReproStatements), "RETURN NONE"); + Assert.Equal("RETURN", stmt.StatementText); + } + + [Fact] + public void MultiplePlan_KeepsItsHashesAndPlaceholderRoot() + { + var stmt = Single(ParseAndAnalyze(ReproStatements), "MULTIPLE PLAN"); + + Assert.Equal("0x3333333333333333", stmt.QueryHash); + Assert.Equal("0x4444444444444444", stmt.QueryPlanHash); + + // No QueryPlan child in the XML, so the placeholder root stays (no operator tree to show). + Assert.NotNull(stmt.RootNode); + Assert.Equal("MULTIPLE PLAN", stmt.RootNode!.PhysicalOp); + } + + [Fact] + public void CondWithQuery_UdfSubPlanUnderCondition_AttachesToTheConditionStatement() + { + // The XSD lets Condition carry UDF sub-plans beside its QueryPlan. They used to become one + // empty STATEMENT placeholder each, with the function's body lost. + var plan = ParseAndAnalyze($""" + + {TableScanRelOp} + + + + + """); + + var statements = plan.Batches.SelectMany(b => b.Statements).ToList(); + Assert.Equal(2, statements.Count); + Assert.DoesNotContain(statements, s => s.StatementType.Length == 0); + + var cond = Single(plan, "COND WITH QUERY"); + var udf = Assert.Single(cond.UdfPlans); + Assert.Equal("[db].[dbo].[f]", udf.ProcName); + Assert.Equal("RETURN", Assert.Single(udf.Statements).StatementType); + } + + [Fact] + public void PlainCondition_WithoutAQueryOfItsOwn_AddsNoStatement() + { + var plan = ParseAndAnalyze(""" + + + + + """); + + var stmt = Assert.Single(plan.Batches.SelectMany(b => b.Statements)); + Assert.Equal("RETURN NONE", stmt.StatementType); + } +} diff --git a/tests/PlanViewer.Core.Tests/WarningBaseline.txt b/tests/PlanViewer.Core.Tests/WarningBaseline.txt index 18794195..bfe3c852 100644 --- a/tests/PlanViewer.Core.Tests/WarningBaseline.txt +++ b/tests/PlanViewer.Core.Tests/WarningBaseline.txt @@ -44,6 +44,7 @@ Parallel Skew | Warning | Thread 3 processed 100% of rows (8,042,005/8,042,005). Bare Scan | Warning | Clustered index scan reads the full table with no predicate, outputting 2 column(s): Badges.Name, Badges.UserId. Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure. For analytical workloads, a columnstore index may be a better fit. ### eager_table_spool_plan.sqlplan +Table-Valued Function | Warning | Table-valued function: INDEXANALYSIS. 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. Data Type Mismatch | Warning | Mismatched data types between the column and the parameter/literal. SQL Server is converting every row to compare, preventing index seeks. Match your data types — don't pass nvarchar to a varchar column, or int to a bigint column. Filter Operator | Warning | Filter operator discarding rows late in the plan.\nPredicate: [Act1007]<>(1) Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: [RebuildVsReorg].[dbo].[RealBigTest].[Sequence] as [R].[Sequence]>[@hkey] AND [RebuildVsReorg].[dbo].[RealBigTest].[Sequence] as [R].[Sequence]<=[@bmax] From cc1884428ae3ba16fbc5c8c29165bcce2717eea0 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:29:55 -0400 Subject: [PATCH 04/85] Fix four analyzer rule gates (#577, #576, #579, #578) #577: rule 5 reported a row-estimate mismatch on operators that never executed. Skip ActualExecutions = 0, as rules 11, 12 and 29 already do. #576: rule 6 dropped its scalar-UDF warning whenever the statement's NonParallelPlanReason was one rule 3 explains, even when rule 3 was disabled or gated out. Suppress it only when rule 3's Serial Plan finding is actually on the statement. #579: rules matched hints and keywords in the raw statement text, so string literals and comments counted as code. Add MaskCommentsAndLiterals and use it for rule 27 (OPTIMIZE FOR UNKNOWN) and for the same pattern in rule 3 (MAXDOP 1), rule 20 (RECOMPILE), rule 28 (NOT IN), rule 37 (cursor declaration) and the rule 26 row-goal cause. #578: rule 30 grouped missing-index suggestions by schema and table, so same-named tables in two databases looked like duplicates. Add the database to the key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Services/PlanAnalyzer.Detection.cs | 2 +- .../Services/PlanAnalyzer.Helpers.cs | 87 +++++- .../Services/PlanAnalyzer.Node.cs | 17 +- .../Services/PlanAnalyzer.Statement.cs | 15 +- .../AnalyzerRuleGateTests.cs | 255 ++++++++++++++++++ 5 files changed, 364 insertions(+), 12 deletions(-) create mode 100644 tests/PlanViewer.Core.Tests/AnalyzerRuleGateTests.cs diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs index 3495640b..266a7bad 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 diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs index cb141850..762b6ec8 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs @@ -281,6 +281,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, 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. */ + private 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 +375,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..f9bee991 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs @@ -151,7 +151,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) @@ -204,10 +208,15 @@ 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) { diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs index fab1ca93..fefb96c6 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs @@ -135,7 +135,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; @@ -306,7 +306,8 @@ 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)); @@ -330,7 +331,7 @@ 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 { @@ -377,7 +378,7 @@ private static void Rule37_CursorWithoutLocal(PlanStatement stmt, AnalyzerConfig // the SELECT. Capturing tokens *before* CURSOR never sees LOCAL and would // fire on every cursor, including ones already declared LOCAL. 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); if (cursorDeclMatch.Success) @@ -451,8 +452,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,7 +463,7 @@ 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) diff --git a/tests/PlanViewer.Core.Tests/AnalyzerRuleGateTests.cs b/tests/PlanViewer.Core.Tests/AnalyzerRuleGateTests.cs new file mode 100644 index 00000000..08299617 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/AnalyzerRuleGateTests.cs @@ -0,0 +1,255 @@ +using System.Linq; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +/// +/// Gates that decide whether an analyzer rule fires at all: #577 (rule 5 on operators that never +/// executed), #576 (rule 6 suppressed on the strength of a rule 3 finding that never appeared), +/// #579 (hints and keywords matched inside string literals and comments, in rule 27 and in every +/// other rule that reads the query text) and #578 (rule 30 merging same-named tables from two +/// databases). The plans are built in code, as in the issues' repros. +/// +public class AnalyzerRuleGateTests +{ + private static ParsedPlan Analyze(PlanStatement stmt, AnalyzerConfig? config = null) + { + var plan = new ParsedPlan { Batches = [new PlanBatch { Statements = [stmt] }] }; + PlanAnalyzer.Analyze(plan, config); + return plan; + } + + private static AnalyzerConfig Disabled(params int[] rules) => + new() { Rules = new RulesConfig { Disabled = rules.ToList() } }; + + private static bool Has(PlanStatement stmt, string warningType) => + stmt.PlanWarnings.Any(w => w.WarningType == warningType); + + private static bool Has(PlanNode node, string warningType) => + node.Warnings.Any(w => w.WarningType == warningType); + + // ---- #577: rule 5 and operators that never executed ------------------------------------ + + private static PlanNode UnexecutedSort(long executions) => new() + { + PhysicalOp = "Sort", + LogicalOp = "Sort", + HasActualStats = true, + ActualExecutions = executions, + ActualRows = 0, + EstimateRows = 1000 + }; + + [Fact] + public void Rule05_OperatorThatNeverExecuted_IsNotAnEstimateMismatch() + { + var node = UnexecutedSort(executions: 0); + Analyze(new PlanStatement { RootNode = node }); + + Assert.False(Has(node, "Row Estimate Mismatch")); + } + + [Fact] + public void Rule05_OperatorThatExecutedAndReturnedNothing_StillWarns() + { + var node = UnexecutedSort(executions: 1); + Analyze(new PlanStatement { RootNode = node }); + + Assert.True(Has(node, "Row Estimate Mismatch")); + } + + // ---- #576: rule 6 is suppressed only by a rule 3 finding that exists -------------------- + + private static (PlanStatement Stmt, PlanNode Node) UdfStatement(double cost) + { + var node = new PlanNode + { + PhysicalOp = "Compute Scalar", + LogicalOp = "Compute Scalar", + ScalarUdfs = [new ScalarUdfReference { FunctionName = "dbo.F" }] + }; + var stmt = new PlanStatement + { + NonParallelPlanReason = "TSQLUserDefinedFunctionsNotParallelizable", + StatementSubTreeCost = cost, + RootNode = node + }; + return (stmt, node); + } + + [Fact] + public void Rule06_Rule3Disabled_ScalarUdfWarningStays() + { + var (stmt, node) = UdfStatement(cost: 10); + Analyze(stmt, Disabled(3)); + + Assert.False(Has(stmt, "Serial Plan")); + Assert.True(Has(node, "Scalar UDF")); + } + + [Fact] + public void Rule06_Rule3GatedOutByCost_ScalarUdfWarningStays() + { + // Rule 3 skips statements that cost under 1: they could never go parallel. + var (stmt, node) = UdfStatement(cost: 0.5); + Analyze(stmt); + + Assert.False(Has(stmt, "Serial Plan")); + Assert.True(Has(node, "Scalar UDF")); + } + + [Fact] + public void Rule06_Rule3ExplainsTheUdf_OneFindingNotTwo() + { + var (stmt, node) = UdfStatement(cost: 10); + Analyze(stmt); + + Assert.Single(stmt.PlanWarnings, w => w.WarningType == "Serial Plan"); + Assert.False(Has(node, "Scalar UDF")); + } + + // ---- #579: text inside string literals and comments is not code ------------------------- + + [Theory] + [InlineData("SELECT 'OPTIMIZE FOR UNKNOWN'")] + [InlineData("SELECT N'it''s OPTIMIZE FOR UNKNOWN'")] + [InlineData("SELECT a FROM dbo.t -- OPTION (OPTIMIZE FOR UNKNOWN)")] + [InlineData("SELECT a FROM dbo.t /* OPTION (OPTIMIZE FOR UNKNOWN) */")] + [InlineData("SELECT a FROM dbo.t /* outer /* inner */ OPTIMIZE FOR UNKNOWN */")] + public void Rule27_HintTextInLiteralOrComment_DoesNotWarn(string text) + { + var stmt = new PlanStatement { StatementText = text }; + Analyze(stmt); + + Assert.False(Has(stmt, "Optimize For Unknown")); + } + + [Theory] + [InlineData("SELECT a FROM dbo.t WHERE b = @b OPTION (OPTIMIZE FOR UNKNOWN)")] + [InlineData("SELECT '--' AS x FROM dbo.t OPTION (OPTIMIZE FOR UNKNOWN)")] + [InlineData("SELECT [it's] FROM dbo.t OPTION (OPTIMIZE FOR UNKNOWN)")] + [InlineData("SELECT a FROM dbo.t /* note */ OPTION (OPTIMIZE FOR UNKNOWN)")] + public void Rule27_RealHint_StillWarns(string text) + { + // A dash pair inside a string, a quote inside a bracketed name, and a closed comment + // before the hint must not hide the hint that follows them. + var stmt = new PlanStatement { StatementText = text }; + Analyze(stmt); + + Assert.True(Has(stmt, "Optimize For Unknown")); + } + + [Theory] + [InlineData("SELECT a FROM dbo.t -- OPTION (MAXDOP 1)", false)] + [InlineData("SELECT a FROM dbo.t OPTION (MAXDOP 1)", true)] + public void Rule03_Maxdop1InACommentIsNotAQueryHint(string text, bool expectWarning) + { + // Without MAXDOP 1 in the query itself, the setting came from the server, the database or + // Resource Governor, and rule 3 stays quiet (untruncated text). + var stmt = new PlanStatement + { + NonParallelPlanReason = "MaxDOPSetToOne", + StatementSubTreeCost = 10, + StatementText = text + }; + Analyze(stmt); + + Assert.Equal(expectWarning, Has(stmt, "Serial Plan")); + } + + [Theory] + [InlineData("SELECT a FROM dbo.t WHERE b = @b /* OPTION (RECOMPILE) */", true)] + [InlineData("SELECT a FROM dbo.t WHERE b = @b OPTION (RECOMPILE)", false)] + public void Rule20_RecompileInACommentDoesNotSilenceTheWarning(string text, bool expectWarning) + { + var stmt = new PlanStatement + { + StatementSubTreeCost = 10, + StatementText = text, + Parameters = [new PlanParameter { Name = "@b" }] + }; + Analyze(stmt); + + Assert.Equal(expectWarning, Has(stmt, "Local Variables")); + } + + [Theory] + [InlineData("-- DECLARE c CURSOR FOR SELECT a FROM dbo.t\nSELECT a FROM dbo.t", false)] + [InlineData("DECLARE c CURSOR /* LOCAL */ FOR SELECT a FROM dbo.t", true)] + [InlineData("DECLARE c CURSOR LOCAL FOR SELECT a FROM dbo.t", false)] + public void Rule37_CursorDeclarationReadsOnlyCode(string text, bool expectWarning) + { + var stmt = new PlanStatement { StatementText = text }; + Analyze(stmt); + + Assert.Equal(expectWarning, Has(stmt, "Cursor Missing LOCAL")); + } + + [Theory] + [InlineData("SELECT a FROM dbo.t WHERE b NOT IN (SELECT c FROM dbo.u)", true)] + [InlineData("SELECT a FROM dbo.t WHERE NOT EXISTS (SELECT 1 FROM dbo.u) -- was NOT IN", false)] + public void Rule28_NotInMustBeInTheCode(string text, bool expectWarning) + { + var spool = new PlanNode + { + PhysicalOp = "Row Count Spool", + LogicalOp = "Lazy Spool", + EstimateRewinds = 20000 + }; + var antiSemiJoin = new PlanNode + { + PhysicalOp = "Nested Loops", + LogicalOp = "Left Anti Semi Join", + Predicate = "[dbo].[u].[c] IS NULL", + Children = { spool } + }; + spool.Parent = antiSemiJoin; + Analyze(new PlanStatement { StatementText = text, RootNode = antiSemiJoin }); + + Assert.Equal(expectWarning, Has(spool, "NOT IN with Nullable Column")); + } + + [Fact] + public void Rule26_RowGoalCauseIgnoresKeywordsInComments() + { + var scan = new PlanNode + { + PhysicalOp = "Index Scan", + LogicalOp = "Index Scan", + EstimateRows = 1, + EstimateRowsWithoutRowGoal = 1000 + }; + Analyze(new PlanStatement + { + StatementText = "SELECT a FROM dbo.t WHERE EXISTS (SELECT 1 FROM dbo.u) -- was TOP (1)", + RootNode = scan + }); + + var warning = Assert.Single(scan.Warnings, w => w.WarningType == "Row Goal"); + Assert.Contains("due to EXISTS.", warning.Message); + } + + // ---- #578: rule 30 keys a table by database, schema and name ----------------------------- + + private static MissingIndex Suggestion(string database) => + new() { Database = database, Schema = "dbo", Table = "T", Impact = 90 }; + + [Fact] + public void Rule30_SameNamedTablesInTwoDatabases_AreNotDuplicates() + { + var stmt = new PlanStatement { MissingIndexes = [Suggestion("A"), Suggestion("B")] }; + Analyze(stmt); + + Assert.False(Has(stmt, "Duplicate Index Suggestions")); + } + + [Fact] + public void Rule30_TwoSuggestionsForOneTable_AreStillDuplicates() + { + var stmt = new PlanStatement { MissingIndexes = [Suggestion("A"), Suggestion("A")] }; + Analyze(stmt); + + Assert.Single(stmt.PlanWarnings, w => w.WarningType == "Duplicate Index Suggestions"); + } +} From dcec793a75842eafca2e48872b274ffd8d14de5d Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:35:35 -0400 Subject: [PATCH 05/85] Mask comments and literals in rule 38 and the parameters panel (#579) Review of #582 found rule 38 still matched MAXDOP 2 against the raw text, so a MAXDOP 2 mentioned in a comment suppressed the Standard Edition DOP warning. The app's parameters panel had the same raw OPTIMIZE FOR UNKNOWN check for its annotation. Both now use MaskCommentsAndLiterals, which is internal so the app can call it. Add direct tests for the helper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Controls/PlanViewerControl.Parameters.cs | 4 +- .../Services/PlanAnalyzer.Helpers.cs | 6 +-- .../Services/PlanAnalyzer.Statement.cs | 2 +- .../AnalyzerRuleGateTests.cs | 38 +++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) 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.Core/Services/PlanAnalyzer.Helpers.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs index 762b6ec8..56d5eb9f 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs @@ -281,15 +281,15 @@ 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, RECOMPILE, - OPTIMIZE FOR UNKNOWN, NOT IN, a cursor declaration, a row goal's cause) matched the raw + /* #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. */ - private static string MaskCommentsAndLiterals(string? text) + internal static string MaskCommentsAndLiterals(string? text) { if (string.IsNullOrEmpty(text)) return ""; diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs index fefb96c6..a56d089d 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs @@ -408,7 +408,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) { diff --git a/tests/PlanViewer.Core.Tests/AnalyzerRuleGateTests.cs b/tests/PlanViewer.Core.Tests/AnalyzerRuleGateTests.cs index 08299617..96f2471a 100644 --- a/tests/PlanViewer.Core.Tests/AnalyzerRuleGateTests.cs +++ b/tests/PlanViewer.Core.Tests/AnalyzerRuleGateTests.cs @@ -111,6 +111,21 @@ public void Rule06_Rule3ExplainsTheUdf_OneFindingNotTwo() // ---- #579: text inside string literals and comments is not code ------------------------- + [Theory] + // String contents and whole comments become spaces; code, quotes and [identifiers] stay put. + [InlineData("SELECT [it's] /* x */ 'y' -- z\nFROM t", "SELECT [it's] ' ' \nFROM t")] + // Block comments nest in T-SQL: the first */ closes only the inner one. + [InlineData("a /* 1 /* 2 */ 3 */ b", "a b")] + // An unclosed string runs to the end, as SQL Server would read it. + [InlineData("SELECT 'abc", "SELECT ' ")] + public void MaskCommentsAndLiterals_BlanksOnlyLiteralsAndComments(string text, string expected) + { + var masked = PlanAnalyzer.MaskCommentsAndLiterals(text); + + Assert.Equal(expected, masked); + Assert.Equal(text.Length, masked.Length); + } + [Theory] [InlineData("SELECT 'OPTIMIZE FOR UNKNOWN'")] [InlineData("SELECT N'it''s OPTIMIZE FOR UNKNOWN'")] @@ -158,6 +173,29 @@ public void Rule03_Maxdop1InACommentIsNotAQueryHint(string text, bool expectWarn Assert.Equal(expectWarning, Has(stmt, "Serial Plan")); } + [Theory] + [InlineData("SELECT a FROM dbo.t -- OPTION (MAXDOP 2)", true)] + [InlineData("SELECT a FROM dbo.t OPTION (MAXDOP 2)", false)] + public void Rule38_Maxdop2InACommentDoesNotExplainTheDopCap(string text, bool expectWarning) + { + // A MAXDOP 2 hint makes DOP 2 intentional, so rule 38 stays quiet. A MAXDOP 2 that is + // only mentioned in a comment is not a hint. No server metadata: the edition is unknown. + var stmt = new PlanStatement + { + DegreeOfParallelism = 2, + StatementText = text, + RootNode = new PlanNode + { + PhysicalOp = "Hash Match", + LogicalOp = "Aggregate", + ActualExecutionMode = "Batch" + } + }; + Analyze(stmt); + + Assert.Equal(expectWarning, Has(stmt, "Standard Edition DOP Limitation")); + } + [Theory] [InlineData("SELECT a FROM dbo.t WHERE b = @b /* OPTION (RECOMPILE) */", true)] [InlineData("SELECT a FROM dbo.t WHERE b = @b OPTION (RECOMPILE)", false)] From de1cac64d3c800e8c607892acb86320ab9f9ccc4 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:44:02 -0400 Subject: [PATCH 06/85] Drop rule 5's unreachable executions fallback The rule now runs only when ActualExecutions > 0, so the fallback to 1 can never apply. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs index f9bee991..abc0ba55 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs @@ -177,7 +177,7 @@ operators the same way. */ else { // Compare per-execution actuals to estimates (SQL Server estimates are per-execution) - var executions = node.ActualExecutions > 0 ? node.ActualExecutions : 1; + var executions = node.ActualExecutions; var actualPerExec = (double)node.ActualRows / executions; var ratio = actualPerExec / node.EstimateRows; if (ratio >= 10.0 || ratio <= 0.1) From dcc06db54477fdd7847d4b1496f9c6bdb114e868 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:44:19 -0400 Subject: [PATCH 07/85] Key severity overrides on the rule that emitted the finding (#575) Each analyzer rule now stamps its number on the findings it adds (PlanWarning.RuleNumber), and TryOverrideSeverity reads that number instead of matching WarningType against a rule-to-name table. The table had no entry for rules 34-37 and 39, for rule 30's Low Impact Index and Duplicate Index Suggestions, or for rule 10's RID Lookup, so overrides for them were silently ignored. The table, its unused reverse map and the static constructor that built it are gone. Engine warnings are still never overridden. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- src/PlanViewer.Core/Models/PlanModels.cs | 11 ++ .../Services/PlanAnalyzer.Helpers.cs | 33 ++-- .../Services/PlanAnalyzer.Node.cs | 26 +++ .../Services/PlanAnalyzer.Statement.cs | 22 +++ src/PlanViewer.Core/Services/PlanAnalyzer.cs | 51 ------ .../SeverityOverrideTests.cs | 169 ++++++++++++++++++ 6 files changed, 241 insertions(+), 71 deletions(-) create mode 100644 tests/PlanViewer.Core.Tests/SeverityOverrideTests.cs diff --git a/src/PlanViewer.Core/Models/PlanModels.cs b/src/PlanViewer.Core/Models/PlanModels.cs index 26746063..f90070b6 100644 --- a/src/PlanViewer.Core/Models/PlanModels.cs +++ b/src/PlanViewer.Core/Models/PlanModels.cs @@ -412,6 +412,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). /// diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Helpers.cs index 56d5eb9f..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)) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs index abc0ba55..bbe22f3e 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 @@ -168,6 +171,7 @@ operators the same way. */ { 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 @@ -192,6 +196,7 @@ operators the same way. */ : $"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 @@ -223,6 +228,7 @@ the UDF warning used to vanish with nothing in its place. Statement rules run be 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 @@ -326,6 +332,7 @@ private static void Rule08_ParallelThreadSkew(PlanNode node, PlanStatement stmt, node.Warnings.Add(new PlanWarning { + RuleNumber = 8, WarningType = "Parallel Skew", Message = message, Severity = severity @@ -348,6 +355,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 @@ -374,6 +382,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 @@ -414,6 +423,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 @@ -461,6 +471,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 @@ -489,6 +500,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. " + @@ -518,6 +530,7 @@ private static void Rule33_CeGuessDetection(PlanNode node, PlanStatement stmt, A { 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. " + @@ -563,6 +576,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 @@ -575,6 +589,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 @@ -601,6 +616,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 @@ -635,6 +651,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 @@ -664,6 +681,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 @@ -733,6 +751,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 @@ -756,6 +775,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." @@ -778,6 +798,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." @@ -796,6 +817,7 @@ private static void Rule23_TableValuedFunctions(PlanNode node, PlanStatement stm 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 @@ -836,6 +858,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 @@ -878,6 +901,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 @@ -900,6 +924,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 @@ -949,6 +974,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 a56d089d..830be28c 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, " @@ -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 @@ -198,6 +202,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 +215,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 +243,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 +260,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 +276,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 +293,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 @@ -313,6 +323,7 @@ private static void Rule20_LocalVariablesNoRecompile(PlanStatement stmt, Analyze 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 @@ -335,6 +346,7 @@ private static void Rule27_OptimizeForUnknown(PlanStatement stmt, AnalyzerConfig { 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 @@ -355,6 +367,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 @@ -388,6 +401,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 @@ -421,6 +435,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 @@ -432,6 +447,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 @@ -470,6 +486,7 @@ private static void Rule30_MissingIndexQuality(PlanStatement stmt, AnalyzerConfi { 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 @@ -481,6 +498,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 @@ -491,6 +509,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 @@ -502,6 +521,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 @@ -532,6 +552,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, @@ -543,6 +564,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.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index d9a58004..eb42bb24 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.cs @@ -154,57 +154,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/tests/PlanViewer.Core.Tests/SeverityOverrideTests.cs b/tests/PlanViewer.Core.Tests/SeverityOverrideTests.cs new file mode 100644 index 00000000..6f749e1b --- /dev/null +++ b/tests/PlanViewer.Core.Tests/SeverityOverrideTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +/// +/// #575: a severity override reaches every finding its rule emits. Overrides used to find a +/// finding's rule by matching its type against a rule-to-name table, and a finding the table had +/// no entry for (rules 34-37 and 39, rule 30's Low Impact Index and Duplicate Index Suggestions, +/// rule 10's RID Lookup) kept its default severity with no error. +/// +public class SeverityOverrideTests +{ + // More numbers than the analyzer has rules, so a new rule is covered without an edit here. + private static readonly int[] EveryRuleNumber = Enumerable.Range(1, 99).ToArray(); + + private static AnalyzerConfig Overrides(IEnumerable rules, PlanWarningSeverity severity) => + new() + { + Rules = new RulesConfig + { + SeverityOverrides = rules.ToDictionary(r => r, _ => severity.ToString()) + } + }; + + private static AnalyzerConfig Disabled(int rule) => + new() { Rules = new RulesConfig { Disabled = [rule] } }; + + private static List Analyze(PlanStatement stmt, AnalyzerConfig? config) + { + var plan = new ParsedPlan { Batches = [new PlanBatch { Statements = [stmt] }] }; + PlanAnalyzer.Analyze(plan, config); + return PlanTestHelper.AllWarnings(plan); + } + + /// + /// Parse and analyze only. The scorer's wait-stats findings come from no rule, so they would + /// only get in the way here. + /// + private static List AnalyzeFixture(string fileName, AnalyzerConfig? config) + { + var xml = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Plans", fileName)) + .Replace("encoding=\"utf-16\"", "encoding=\"utf-8\""); + var plan = ShowPlanParser.Parse(xml); + PlanAnalyzer.Analyze(plan, config); + return PlanTestHelper.AllWarnings(plan); + } + + [Fact] + public void IssueRepro_Rule36AndRule30OverridesApply() + { + var stmt = new PlanStatement + { + CursorActualType = "Dynamic", + MissingIndexes = [new MissingIndex { Table = "T", Impact = 10 }] + }; + var warnings = Analyze(stmt, new AnalyzerConfig + { + Rules = new RulesConfig { SeverityOverrides = { [36] = "Info", [30] = "Critical" } } + }); + + Assert.Equal(PlanWarningSeverity.Info, + Assert.Single(warnings, w => w.WarningType == "Dynamic Cursor").Severity); + Assert.Equal(PlanWarningSeverity.Critical, + Assert.Single(warnings, w => w.WarningType == "Low Impact Index").Severity); + } + + private static MissingIndex Suggestion(double impact = 90, int includes = 0) => new() + { + Database = "D", + Schema = "dbo", + Table = "T", + Impact = impact, + EqualityColumns = ["a"], + IncludeColumns = Enumerable.Range(1, includes).Select(i => $"c{i}").ToList() + }; + + private static PlanStatement StatementThatEmits(string warningType) => warningType switch + { + "Dynamic Cursor" => new PlanStatement { CursorActualType = "Dynamic" }, + "Cursor Missing LOCAL" => new PlanStatement { StatementText = "DECLARE c CURSOR FOR SELECT a FROM dbo.t" }, + "Truncated Query Text" => new PlanStatement + { + StatementText = "SELECT " + new string('a', PlanStatement.TruncationLengthThreshold) + }, + "Low Impact Index" => new PlanStatement { MissingIndexes = [Suggestion(impact: 10)] }, + "Wide Index Suggestion" => new PlanStatement { MissingIndexes = [Suggestion(includes: 6)] }, + "Duplicate Index Suggestions" => new PlanStatement { MissingIndexes = [Suggestion(), Suggestion()] }, + _ => throw new ArgumentOutOfRangeException(nameof(warningType), warningType, null) + }; + + [Theory] + [InlineData("Dynamic Cursor", 36)] + [InlineData("Cursor Missing LOCAL", 37)] + [InlineData("Truncated Query Text", 39)] + [InlineData("Low Impact Index", 30)] + [InlineData("Wide Index Suggestion", 30)] + [InlineData("Duplicate Index Suggestions", 30)] + public void StatementFindingTakesItsRulesOverride(string warningType, int rule) => + AssertOverrideReaches(config => Analyze(StatementThatEmits(warningType), config), warningType, rule); + + [Theory] + [InlineData("rid_lookup_plan.sqlplan", "RID Lookup", 10)] + [InlineData("cte_multi_ref_plan.sqlplan", "Bare Scan", 34)] + [InlineData("excellent-parallel-spill.sqlplan", "Expensive Operator", 35)] + public void OperatorFindingTakesItsRulesOverride(string fixture, string warningType, int rule) => + AssertOverrideReaches(config => AnalyzeFixture(fixture, config), warningType, rule); + + private static void AssertOverrideReaches( + Func> analyze, string warningType, int rule) + { + var byDefault = analyze(null).Where(w => w.WarningType == warningType).ToList(); + Assert.NotEmpty(byDefault); + + // A severity the rule did not give at least one of them, so it can only come from the override. + var target = byDefault.Any(w => w.Severity != PlanWarningSeverity.Info) + ? PlanWarningSeverity.Info + : PlanWarningSeverity.Critical; + var overridden = analyze(Overrides([rule], target)).Where(w => w.WarningType == warningType).ToList(); + + Assert.Equal(byDefault.Count, overridden.Count); + Assert.All(overridden, w => Assert.Equal(target, w.Severity)); + } + + public static TheoryData Fixtures() => + new(Directory.GetFiles(Path.Combine(AppContext.BaseDirectory, "Plans"), "*.sqlplan") + .Select(f => Path.GetFileName(f)) + .OrderBy(f => f, StringComparer.Ordinal)); + + /// + /// Over the whole plan corpus: every finding the analyzer makes names the rule that made it, + /// that rule's switch is the one that turns it off, and that rule's override sets its + /// severity. The engine's own warnings name no rule and keep their severity whatever is + /// overridden, as before #575. + /// + [Theory] + [MemberData(nameof(Fixtures))] + public void EveryAnalyzerFindingFollowsItsRule(string fixture) + { + var byDefault = AnalyzeFixture(fixture, null); + var ours = byDefault.Where(w => w.Source == PlanWarningSource.PerformanceStudio).ToList(); + + Assert.All(ours, w => Assert.True(w.RuleNumber.HasValue, $"{w.WarningType} names no rule")); + Assert.All(byDefault.Where(w => w.Source == PlanWarningSource.SqlServer), + w => Assert.Null(w.RuleNumber)); + + foreach (var rule in ours.Select(w => w.RuleNumber!.Value).Distinct()) + Assert.DoesNotContain(AnalyzeFixture(fixture, Disabled(rule)), w => w.RuleNumber == rule); + + foreach (var target in new[] { PlanWarningSeverity.Info, PlanWarningSeverity.Critical }) + { + var overridden = AnalyzeFixture(fixture, Overrides(EveryRuleNumber, target)); + + // An override changes severity only, so both runs list the same findings in the same order. + Assert.Equal(byDefault.Select(w => w.WarningType), overridden.Select(w => w.WarningType)); + for (var i = 0; i < byDefault.Count; i++) + { + var expected = byDefault[i].Source == PlanWarningSource.SqlServer + ? byDefault[i].Severity + : target; + Assert.Equal(expected, overridden[i].Severity); + } + } + } +} From 7f5004bb18de9eb3cb727d400a661163582f4367 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:21:15 -0400 Subject: [PATCH 08/85] Skip the engine's own functions in rule 23 STRING_SPLIT, OPENJSON, GENERATE_SERIES and every DMV and DMF run as a Table-valued function operator whose Object has no Database and no Schema. A user function always has both. Rule 23's advice (rewrite as an inline function, or stage the rows in a #temp table) is for code the user wrote, so the rule now skips an operator with neither part. The parser records the schema on PlanNode.SchemaName for the check. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- src/PlanViewer.Core/Models/PlanModels.cs | 1 + .../Services/PlanAnalyzer.Node.cs | 8 +++- .../Services/ShowPlanParser.RelOp.cs | 1 + .../PlanAnalyzerTests.cs | 41 +++++++++++++++++++ .../PlanViewer.Core.Tests/WarningBaseline.txt | 1 - 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/PlanViewer.Core/Models/PlanModels.cs b/src/PlanViewer.Core/Models/PlanModels.cs index f90070b6..66b25cc8 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; } diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs index bbe22f3e..b7abeb61 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs @@ -812,7 +812,13 @@ 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 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/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs index 8dd60c32..8cd6aef5 100644 --- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs @@ -1153,6 +1153,47 @@ public void Rule23_TableValuedFunction_DetectsTvfOperator() Assert.Contains("GetTopPosts", warnings[0].Message); } + [Fact] + public void Rule23_EngineFunction_IsNotFlagged() + { + // sys.dm_db_index_physical_stats runs as the engine's INDEXANALYSIS function. Its Object + // names no database and no schema, and the multi-statement TVF advice does not apply. + var plan = PlanTestHelper.LoadAndAnalyze("eager_table_spool_plan.sqlplan"); + + static IEnumerable Walk(PlanNode node) => node.Children.SelectMany(Walk).Prepend(node); + var tvf = Assert.Single( + PlanStatements.EnumerateAll(plan).Where(s => s.RootNode != null).SelectMany(s => Walk(s.RootNode!)), + n => n.LogicalOp == "Table-valued function"); + Assert.Equal("INDEXANALYSIS", tvf.ObjectName); + Assert.Empty(PlanTestHelper.WarningsOfType(plan, "Table-Valued Function")); + } + + [Theory] + // STRING_SPLIT, OPENJSON or a DMV: the engine's own function. + [InlineData(null, null, false)] + // A function a user wrote. Only an Object with neither part counts as the engine's. + [InlineData("StackOverflow2013", "dbo", true)] + [InlineData("StackOverflow2013", null, true)] + [InlineData(null, "dbo", true)] + public void Rule23_WarnsUnlessTheFunctionHasNoDatabaseAndNoSchema( + string? database, string? schema, bool expectWarning) + { + var node = new PlanNode + { + PhysicalOp = "Table-valued function", + LogicalOp = "Table-valued function", + DatabaseName = database, + SchemaName = schema, + ObjectName = "F" + }; + PlanAnalyzer.Analyze(new ParsedPlan + { + Batches = [new PlanBatch { Statements = [new PlanStatement { RootNode = node }] }] + }); + + Assert.Equal(expectWarning, node.Warnings.Any(w => w.WarningType == "Table-Valued Function")); + } + // --------------------------------------------------------------- // Rule 24: Top Above Scan // --------------------------------------------------------------- diff --git a/tests/PlanViewer.Core.Tests/WarningBaseline.txt b/tests/PlanViewer.Core.Tests/WarningBaseline.txt index bfe3c852..18794195 100644 --- a/tests/PlanViewer.Core.Tests/WarningBaseline.txt +++ b/tests/PlanViewer.Core.Tests/WarningBaseline.txt @@ -44,7 +44,6 @@ Parallel Skew | Warning | Thread 3 processed 100% of rows (8,042,005/8,042,005). Bare Scan | Warning | Clustered index scan reads the full table with no predicate, outputting 2 column(s): Badges.Name, Badges.UserId. Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure. For analytical workloads, a columnstore index may be a better fit. ### eager_table_spool_plan.sqlplan -Table-Valued Function | Warning | Table-valued function: INDEXANALYSIS. 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. Data Type Mismatch | Warning | Mismatched data types between the column and the parameter/literal. SQL Server is converting every row to compare, preventing index seeks. Match your data types — don't pass nvarchar to a varchar column, or int to a bigint column. Filter Operator | Warning | Filter operator discarding rows late in the plan.\nPredicate: [Act1007]<>(1) Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: [RebuildVsReorg].[dbo].[RealBigTest].[Sequence] as [R].[Sequence]>[@hkey] AND [RebuildVsReorg].[dbo].[RealBigTest].[Sequence] as [R].[Sequence]<=[@bmax] From 5d56d3c3dd262142caa55d52143bd279d5bc2dac Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:35:22 -0400 Subject: [PATCH 09/85] Keep an all-hex temp table name whole CleanTempTableName found an internal temp table name's hex suffix by skipping trailing hex digits. For a name that is all hex after the #, such as #deadbeef1 or a table variable's internal name like #A1B2C3D4, the skip ran to the start and the name came back as a bare #. Ported from PerformanceMonitor b31e5d18: a name with no underscore padding before its hex run is returned unchanged, and the function never returns a bare #. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Services/ShowPlanParser.Helpers.cs | 12 +++++- .../TempTableNameTests.cs | 37 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 tests/PlanViewer.Core.Tests/TempTableNameTests.cs diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.Helpers.cs b/src/PlanViewer.Core/Services/ShowPlanParser.Helpers.cs index 9bc1978f..e0ebf4b6 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; diff --git a/tests/PlanViewer.Core.Tests/TempTableNameTests.cs b/tests/PlanViewer.Core.Tests/TempTableNameTests.cs new file mode 100644 index 00000000..860b2b84 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/TempTableNameTests.cs @@ -0,0 +1,37 @@ +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +/// +/// SQL Server stores a #temp table under its visible name padded with underscores to 116 +/// characters, followed by a 12-character hex suffix, and plans show that internal name. +/// CleanTempTableName strips the padding and the suffix. A name with no padding before its hex +/// run is not that pattern, and it must come back unchanged. Before the guard ported from +/// PerformanceMonitor b31e5d18, an all-hex name such as "#deadbeef1", or a table variable's +/// internal name such as "#A1B2C3D4", came back as a bare "#". +/// +public class TempTableNameTests +{ + [Theory] + [InlineData("#u", "000000012653")] + [InlineData("#OldUsers", "00000000000C")] + [InlineData("#deadbeef", "00000000000A")] + public void InternalName_ReducesToTheVisibleName(string visibleName, string suffix) + { + var internalName = visibleName.PadRight(116, '_') + suffix; + + Assert.Equal(visibleName, ShowPlanParser.CleanTempTableName(internalName)); + } + + [Theory] + [InlineData("#deadbeef1")] + [InlineData("#A1B2C3D4")] + [InlineData("#OldUsers")] + [InlineData("#t")] + [InlineData("##global")] + [InlineData("Users")] + public void NameWithoutPadding_IsUnchanged(string name) + { + Assert.Equal(name, ShowPlanParser.CleanTempTableName(name)); + } +} From 1c06c6bd4e76495315bc856abce365351c5ad1bb Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:39:07 -0400 Subject: [PATCH 10/85] Cover an internal temp name with nothing before its padding Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- tests/PlanViewer.Core.Tests/TempTableNameTests.cs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/PlanViewer.Core.Tests/TempTableNameTests.cs b/tests/PlanViewer.Core.Tests/TempTableNameTests.cs index 860b2b84..233536fa 100644 --- a/tests/PlanViewer.Core.Tests/TempTableNameTests.cs +++ b/tests/PlanViewer.Core.Tests/TempTableNameTests.cs @@ -34,4 +34,14 @@ public void NameWithoutPadding_IsUnchanged(string name) { Assert.Equal(name, ShowPlanParser.CleanTempTableName(name)); } + + [Fact] + public void InternalNameWithNothingBeforeThePadding_IsNotCollapsedToAHash() + { + // Every character between the # and the hex suffix is an underscore, so no name is left + // once the padding is stripped. The name comes back whole instead of as a bare "#". + var internalName = "#".PadRight(116, '_') + "00000000000A"; + + Assert.Equal(internalName, ShowPlanParser.CleanTempTableName(internalName)); + } } From 37d2ec3e450193b7fdbcc3ebabeb73552c631f93 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:27:18 -0400 Subject: [PATCH 11/85] Harden HTML export, repro script header, and CLI encryption - HtmlExporter: map warning severity to a fixed CSS class name instead of writing the raw value into class attributes. A null severity no longer throws. - ReproScriptBuilder: split "*/" and "/*" and fold line breaks in every value written into the header comment, so a plan's database name cannot end it. - CliConnectionResolver: --trust-cert keeps encryption Mandatory, matching the direct-login path in ConnectionHelper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Commands/CliConnectionResolver.cs | 5 +- src/PlanViewer.Core/Output/HtmlExporter.cs | 19 +++++-- .../Services/ReproScriptBuilder.cs | 24 +++++++-- .../CliConnectionResolverTests.cs | 25 +++++++++ .../HtmlExporterTests.cs | 48 +++++++++++++++++ .../ReproScriptBuilderSafetyTests.cs | 53 +++++++++++++++++++ 6 files changed, 166 insertions(+), 8 deletions(-) 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.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index 4d2b1342..a174b590 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -509,9 +509,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)}"); @@ -619,4 +621,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/Services/ReproScriptBuilder.cs b/src/PlanViewer.Core/Services/ReproScriptBuilder.cs index a7f8a59b..35f7df44 100644 --- a/src/PlanViewer.Core/Services/ReproScriptBuilder.cs +++ b/src/PlanViewer.Core/Services/ReproScriptBuilder.cs @@ -111,13 +111,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($"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 +128,7 @@ rather than leaving an unexplained placeholder. */ sb.AppendLine("Warnings:"); foreach (var warning in warnings) { - sb.AppendLine($" - {warning}"); + sb.AppendLine($" - {CommentSafe(warning)}"); } } @@ -408,6 +409,21 @@ private static string EscapeSqlString(string value) return value.Replace("'", "''"); } + /// + /// 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 a value stays on its own line and can never + /// put GO on a line by itself for a batch splitter that doesn't track comments. + /// + 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. /// Anything else is dropped from the generated script. diff --git a/tests/PlanViewer.Core.Tests/CliConnectionResolverTests.cs b/tests/PlanViewer.Core.Tests/CliConnectionResolverTests.cs index b71450b9..82ab1e74 100644 --- a/tests/PlanViewer.Core.Tests/CliConnectionResolverTests.cs +++ b/tests/PlanViewer.Core.Tests/CliConnectionResolverTests.cs @@ -1,3 +1,5 @@ +using Microsoft.Data.SqlClient; +using PlanViewer.Cli; using PlanViewer.Cli.Commands; using PlanViewer.Core.Interfaces; using PlanViewer.Core.Services; @@ -55,6 +57,29 @@ instead of leaking. */ } } + /* --trust-cert skips certificate validation and nothing else. It used to make encryption + optional as well, unlike the direct-login path in ConnectionHelper, which always kept it + mandatory. Both paths must agree. */ + [Theory] + [InlineData("sql", false)] + [InlineData("sql", true)] + [InlineData("windows", false)] + [InlineData("windows", true)] + public void BuildServerConnection_KeepsEncryptionMandatory(string auth, bool trustCert) + { + var store = new InMemoryCredentialService(); + store.SaveCredential("srv", "user", "pass"); + + var connection = CliConnectionResolver.BuildServerConnection("srv", auth, trustCert, store); + var resolved = new SqlConnectionStringBuilder(connection.GetConnectionString(store)); + var direct = new SqlConnectionStringBuilder( + ConnectionHelper.BuildConnectionString("srv", "master", "user", "pass", trustCert)); + + Assert.Equal(SqlConnectionEncryptOption.Mandatory, resolved.Encrypt); + Assert.Equal(trustCert, resolved.TrustServerCertificate); + Assert.Equal(direct.Encrypt, resolved.Encrypt); + } + /* Minimal stand-in: the resolver only asks whether a credential exists, and the entra refusal must fire before credentials ever matter. */ private sealed class NoCredentials : ICredentialService diff --git a/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs b/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs index b26fb9d4..1f82611e 100644 --- a/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs +++ b/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs @@ -63,4 +63,52 @@ public void Export_EscapesHtmlInQueryText() Assert.Contains("", html); Assert.Contains("", html); } + + [Theory] + [InlineData("Critical", "critical")] + [InlineData("Warning", "warning")] + [InlineData("Info", "info")] + public void Export_KnownSeverity_KeepsItsClass(string severity, string cssClass) + { + var html = ExportWithSeverity(severity); + + Assert.Contains($"
", html); + Assert.Contains($"{severity}", html); + } + + [Fact] + public void Export_CraftedSeverity_CannotLeaveTheClassAttribute() + { + // A shared plan's analysis is caller-supplied JSON, so severity can hold markup. + var html = ExportWithSeverity("\">
alert(1)", html); + Assert.Contains("
", html); + Assert.Contains("<script>alert(1)</script>", html); + } + + [Fact] + public void Export_NullSeverity_ExportsAsInfo() + { + // JSON can send "severity": null, and the export used to throw on it. + var html = ExportWithSeverity(null); + + Assert.Contains("
", html); + } + + private static string ExportWithSeverity(string? severity) + { + var result = new AnalysisResult + { + Statements = + { + new StatementResult + { + StatementText = "SELECT 1", + Warnings = { new WarningResult { Severity = severity!, Type = "demo", Message = "demo" } } + } + } + }; + return HtmlExporter.Export(result, "demo"); + } } diff --git a/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs b/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs index 68549b5e..838e7af8 100644 --- a/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs +++ b/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs @@ -1,3 +1,4 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; using PlanViewer.Core.Services; namespace PlanViewer.Core.Tests; @@ -126,6 +127,58 @@ public void BuildReproScript_RealWorldCompiledValues_SurviveTheFilter( Assert.DoesNotContain("@p = ?", sql); } + // The header comment shows the plan's database name. A crafted name must stay inside it: + // "*/" would close the comment, "/*" would open a nested one that swallows the script, + // and a line break could put GO on a line of its own. ScriptDom parses each script, so a + // statement the name smuggled out would show up as a PRINT or an extra batch. + [Theory] + [InlineData("master*/\nGO\nPRINT 'INJECTED';\nGO\n/*")] // its own batch in a GO-aware client + [InlineData("master*/ PRINT 'INJECTED'; /*")] // same batch, no GO needed + [InlineData("master\r\nGO\r\nPRINT 'INJECTED';\r\nGO")] // line breaks alone + [InlineData("master/*")] // nested comment + public void BuildReproScript_HostileDatabaseName_StaysInTheHeaderComment(string databaseName) + { + var sql = ReproScriptBuilder.BuildReproScript("SELECT 1", databaseName, null, null); + + var script = ParseScript(sql); + Assert.Single(script.Batches); + Assert.DoesNotContain(script.Batches[0].Statements, s => s is PrintStatement); + + var header = HeaderComment(sql); + Assert.Contains("Database: [master", header); + Assert.DoesNotContain(header.Split('\n'), line => line.Trim() == "GO"); + } + + [Fact] + public void BuildReproScript_HostileSource_StaysInTheHeaderComment() + { + var sql = ReproScriptBuilder.BuildReproScript( + "SELECT 1", "db", null, null, source: "x*/ PRINT 'INJECTED'; /*"); + + var script = ParseScript(sql); + Assert.DoesNotContain(script.Batches.SelectMany(b => b.Statements), s => s is PrintStatement); + Assert.Contains("Source: x* / PRINT 'INJECTED'; / *", HeaderComment(sql)); + } + + private static TSqlScript ParseScript(string sql) + { + var fragment = new TSql160Parser(initialQuotedIdentifiers: true) + .Parse(new StringReader(sql), out var errors); + Assert.Empty(errors); + return (TSqlScript)fragment; + } + + // Everything up to the first "*/", which must be the header's own closing line: a value + // that ended the comment early would put it somewhere else. + private static string HeaderComment(string sql) + { + Assert.StartsWith("/*", sql); + var end = sql.IndexOf("*/", StringComparison.Ordinal); + Assert.Equal('\n', sql[end - 1]); + Assert.DoesNotContain("/*", sql[2..end]); + return sql[..end]; + } + [Fact] public void ExtractParametersFromPlan_StillReturnsRawParameters() { From ae75efc014807f76cce678a203ee47a075ba2334 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:34:07 -0400 Subject: [PATCH 12/85] Name Performance Studio in the repro script header The header line still named SQL Server Performance Monitor, the product this code was ported from. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- src/PlanViewer.Core/Services/ReproScriptBuilder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PlanViewer.Core/Services/ReproScriptBuilder.cs b/src/PlanViewer.Core/Services/ReproScriptBuilder.cs index 35f7df44..1136901f 100644 --- a/src/PlanViewer.Core/Services/ReproScriptBuilder.cs +++ b/src/PlanViewer.Core/Services/ReproScriptBuilder.cs @@ -114,7 +114,7 @@ rather than leaving an unexplained placeholder. */ /* 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("Reproduction script generated by Performance Studio"); sb.AppendLine($"Source: {CommentSafe(source)}"); if (!string.IsNullOrEmpty(databaseName)) { From 38ea2b6dabe9c50ec06329fdd1fc013732db2c42 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:02:37 -0400 Subject: [PATCH 13/85] Add review test cases and note the USE line's scope - Header cases for overlapping delimiters and for VT, FF, NEL, U+2028 and U+2029. - HTML export cases for an attribute payload with no markup and for a warning on an operator. - Comments say why the USE line keeps line breaks in the name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Services/ReproScriptBuilder.cs | 7 ++-- .../HtmlExporterTests.cs | 41 +++++++++++++------ .../ReproScriptBuilderSafetyTests.cs | 11 ++++- 3 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/PlanViewer.Core/Services/ReproScriptBuilder.cs b/src/PlanViewer.Core/Services/ReproScriptBuilder.cs index 1136901f..a8c1a7c7 100644 --- a/src/PlanViewer.Core/Services/ReproScriptBuilder.cs +++ b/src/PlanViewer.Core/Services/ReproScriptBuilder.cs @@ -136,7 +136,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("]", "]]")}];"); @@ -412,8 +413,8 @@ private static string EscapeSqlString(string value) /// /// 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 a value stays on its own line and can never - /// put GO on a line by itself for a batch splitter that doesn't track comments. + /// 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) { diff --git a/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs b/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs index 1f82611e..f316bdbb 100644 --- a/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs +++ b/tests/PlanViewer.Core.Tests/HtmlExporterTests.cs @@ -96,19 +96,34 @@ public void Export_NullSeverity_ExportsAsInfo() Assert.Contains("
", html); } - private static string ExportWithSeverity(string? severity) + [Fact] + public void Export_CraftedSeverityWithoutMarkup_CannotAddAnAttribute() + { + var html = ExportWithSeverity("x\" onmouseover=\"alert(1)"); + + Assert.DoesNotContain("onmouseover=\"alert(1)\"", html); + Assert.Contains("
", html); + } + + [Fact] + public void Export_CraftedSeverityOnAnOperator_IsMappedToo() + { + // Operator warnings reach the same list through the operator tree. + var html = ExportWithSeverity("\">
alert(1)", html); + Assert.Contains("
", html); + } + + private static string ExportWithSeverity(string? severity, bool onOperator = false) { - var result = new AnalysisResult - { - Statements = - { - new StatementResult - { - StatementText = "SELECT 1", - Warnings = { new WarningResult { Severity = severity!, Type = "demo", Message = "demo" } } - } - } - }; - return HtmlExporter.Export(result, "demo"); + var warning = new WarningResult { Severity = severity!, Type = "demo", Message = "demo" }; + var statement = new StatementResult { StatementText = "SELECT 1" }; + if (onOperator) + statement.OperatorTree = new OperatorResult { Warnings = { warning } }; + else + statement.Warnings.Add(warning); + + return HtmlExporter.Export(new AnalysisResult { Statements = { statement } }, "demo"); } } diff --git a/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs b/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs index 838e7af8..6ddf472c 100644 --- a/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs +++ b/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs @@ -131,11 +131,18 @@ public void BuildReproScript_RealWorldCompiledValues_SurviveTheFilter( // "*/" would close the comment, "/*" would open a nested one that swallows the script, // and a line break could put GO on a line of its own. ScriptDom parses each script, so a // statement the name smuggled out would show up as a PRINT or an extra batch. + // + // The USE line keeps the name as it is, line breaks included, on purpose. It doubles "]", + // and go-sqlcmd and ODBC sqlcmd do not split a batch inside a bracketed name. A client that + // splits at every GO line is out of scope: the statement text can hold such a line too. [Theory] [InlineData("master*/\nGO\nPRINT 'INJECTED';\nGO\n/*")] // its own batch in a GO-aware client [InlineData("master*/ PRINT 'INJECTED'; /*")] // same batch, no GO needed [InlineData("master\r\nGO\r\nPRINT 'INJECTED';\r\nGO")] // line breaks alone [InlineData("master/*")] // nested comment + [InlineData("master/*/")] // delimiters that overlap + [InlineData("master*/*")] + [InlineData("master\vGO\fPRINT 'INJECTED';\u0085GO\u2028x\u2029y")] // VT, FF, NEL, LS, PS public void BuildReproScript_HostileDatabaseName_StaysInTheHeaderComment(string databaseName) { var sql = ReproScriptBuilder.BuildReproScript("SELECT 1", databaseName, null, null); @@ -145,7 +152,9 @@ public void BuildReproScript_HostileDatabaseName_StaysInTheHeaderComment(string Assert.DoesNotContain(script.Batches[0].Statements, s => s is PrintStatement); var header = HeaderComment(sql); - Assert.Contains("Database: [master", header); + var databaseLine = Assert.Single(header.Split('\n'), line => line.StartsWith("Database: [", StringComparison.Ordinal)); + Assert.StartsWith("Database: [master", databaseLine); + Assert.DoesNotMatch(@"[\p{Cc}\u2028\u2029]", databaseLine.TrimEnd('\r')); Assert.DoesNotContain(header.Split('\n'), line => line.Trim() == "GO"); } From d038f1c403e7fed3fa4bfed86aa8d08dfe856d2b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 08:46:18 -0400 Subject: [PATCH 14/85] Say on stderr which settings came from a .env file A .env file in the working directory can pick the server and turn off certificate validation for analyze and query-store. The CLI applied those settings without a word. Now it prints one line on stderr that names the file and the settings it supplied, never their values. A setting that a command-line option overrode is not listed. PasswordResolver asks for the .env password only when neither --password-stdin nor --password gave one, so the list is exact. Its doc comment and --password warning no longer mention a PLANVIEW_PASSWORD environment variable, which the CLI never read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- README.md | 6 + src/PlanViewer.Cli/Commands/AnalyzeCommand.cs | 14 ++- .../Commands/QueryStoreCommand.cs | 10 +- src/PlanViewer.Cli/ConnectionHelper.cs | 10 +- src/PlanViewer.Cli/EnvFile.cs | 58 +++++++++ src/PlanViewer.Cli/PasswordResolver.cs | 10 +- tests/PlanViewer.Core.Tests/EnvFileTests.cs | 117 ++++++++++++++++++ 7 files changed, 208 insertions(+), 17 deletions(-) create mode 100644 src/PlanViewer.Cli/EnvFile.cs create mode 100644 tests/PlanViewer.Core.Tests/EnvFileTests.cs diff --git a/README.md b/README.md index 847bbeb1..4139a8da 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,12 @@ 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 +``` + **Using the credential store** — for longer-term use, store credentials in your OS keychain: ```bash diff --git a/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs b/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs index ef294fb4..8f9ca944 100644 --- a/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs +++ b/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs @@ -155,23 +155,27 @@ 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) + server ??= env.Use("PLANVIEW_SERVER"); + database ??= env.Use("PLANVIEW_DATABASE"); + login ??= env.Use("PLANVIEW_LOGIN"); + if (!trustCert && env.UseFlag("PLANVIEW_TRUST_CERT")) trustCert = true; // 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.Use("PLANVIEW_PASSWORD"), 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, diff --git a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs index e7b7ee00..e511cb55 100644 --- a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs +++ b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs @@ -227,20 +227,24 @@ 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) + login ??= env.Use("PLANVIEW_LOGIN"); + if (!trustCert && env.UseFlag("PLANVIEW_TRUST_CERT")) trustCert = true; // Resolve password from --password-stdin, --password, or PLANVIEW_PASSWORD if (!PasswordResolver.TryResolve( passwordInline, passwordStdin, stdinAlreadyClaimed: false, - env.GetValueOrDefault("PLANVIEW_PASSWORD"), + () => env.Use("PLANVIEW_PASSWORD"), 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"); 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..8234da51 --- /dev/null +++ b/src/PlanViewer.Cli/EnvFile.cs @@ -0,0 +1,58 @@ +namespace PlanViewer.Cli; + +/// +/// Connection settings from the .env file in the working directory. Command-line options win over +/// the file, so a command asks for a value only when its option was 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; + } + + /// The full path of the file, or null when the directory has no .env file. + public string? FilePath { get; } + + /// Returns the file's value for , or null, and records a hit. + public string? Use(string key) + { + if (!_values.TryGetValue(key, out var value)) + return null; + + Record(key); + return value; + } + + /// + /// True when the file sets to "true". Only then is the key recorded, + /// because any other value leaves the setting as it was. + /// + public bool UseFlag(string key) + { + if (!_values.TryGetValue(key, out var value) || !value.Equals("true", StringComparison.OrdinalIgnoreCase)) + return false; + + Record(key); + return true; + } + + /// + /// 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 {FilePath}: {string.Join(", ", _used)}"; + + private void Record(string key) + { + if (!_used.Contains(key, StringComparer.OrdinalIgnoreCase)) + _used.Add(key); + } +} diff --git a/src/PlanViewer.Cli/PasswordResolver.cs b/src/PlanViewer.Cli/PasswordResolver.cs index ed50bb11..a7f137eb 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 { @@ -20,7 +20,7 @@ public static bool TryResolve( string? inlinePassword, bool passwordFromStdin, bool stdinAlreadyClaimed, - string? envPassword, + Func envPassword, out string? password) { password = null; @@ -52,12 +52,12 @@ public static bool TryResolve( { Console.Error.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/tests/PlanViewer.Core.Tests/EnvFileTests.cs b/tests/PlanViewer.Core.Tests/EnvFileTests.cs new file mode 100644 index 00000000..6299ea3b --- /dev/null +++ b/tests/PlanViewer.Core.Tests/EnvFileTests.cs @@ -0,0 +1,117 @@ +using PlanViewer.Cli; + +namespace PlanViewer.Core.Tests; + +/// +/// A .env file in the working directory can pick the server and turn off certificate validation. +/// The CLI names the file and the settings it supplied on stderr, and never their values. +/// +/// Shares a collection with because one test here swaps +/// Console.Error, as that class does. An uncaptured stderr write in this test host has aborted a whole +/// run before, so the two swaps must not interleave. +/// +[Collection("EntraInteractiveAuth process-wide state")] +public class EnvFileTests : IDisposable +{ + private readonly List _directories = []; + + [Fact] + public void Notice_NamesTheFileAndOnlyTheSettingsItSupplied() + { + var env = Load(""" + # comment + PLANVIEW_SERVER=elsewhere + PLANVIEW_DATABASE='db' + PLANVIEW_TRUST_CERT=TRUE + """); + + // The command had --server, so it never asks the file for PLANVIEW_SERVER. + Assert.Equal("db", env.Use("PLANVIEW_DATABASE")); + Assert.True(env.UseFlag("PLANVIEW_TRUST_CERT")); + + Assert.Equal($"Using settings from {env.FilePath}: PLANVIEW_DATABASE, PLANVIEW_TRUST_CERT", env.Notice); + Assert.True(Path.IsPathFullyQualified(env.FilePath!)); + } + + [Fact] + public void Notice_NeverShowsAValue() + { + var env = Load("PLANVIEW_PASSWORD=\"s3cret\""); + + Assert.Equal("s3cret", env.Use("PLANVIEW_PASSWORD")); + Assert.DoesNotContain("s3cret", env.Notice); + Assert.EndsWith(": PLANVIEW_PASSWORD", env.Notice); + } + + [Fact] + public void TrustCertOtherThanTrue_ChangesNothingAndIsNotReported() + { + var env = Load("PLANVIEW_TRUST_CERT=false"); + + Assert.False(env.UseFlag("PLANVIEW_TRUST_CERT")); + Assert.Null(env.Notice); + } + + [Fact] + public void NoEnvFile_SuppliesNothing() + { + var env = ConnectionHelper.LoadEnvFile(NewDirectory()); + + Assert.Null(env.FilePath); + Assert.Null(env.Use("PLANVIEW_SERVER")); + Assert.Null(env.Notice); + } + + [Fact] + public void PasswordFromTheFile_IsReportedWhenNoOptionGaveOne() + { + var env = Load("PLANVIEW_PASSWORD=fromfile"); + + Assert.True(PasswordResolver.TryResolve(null, false, false, () => env.Use("PLANVIEW_PASSWORD"), out var password)); + + Assert.Equal("fromfile", password); + Assert.EndsWith(": PLANVIEW_PASSWORD", env.Notice); + } + + [Fact] + public void PasswordFromTheFile_IsNotAskedForWhenAnOptionGaveOne() + { + var env = Load("PLANVIEW_PASSWORD=fromfile"); + + /* --password writes its process-listing warning to stderr, so capture the stream for the call. */ + var realError = Console.Error; + Console.SetError(new StringWriter()); + try + { + Assert.True(PasswordResolver.TryResolve("inline", false, false, () => env.Use("PLANVIEW_PASSWORD"), out var password)); + Assert.Equal("inline", password); + } + finally + { + Console.SetError(realError); + } + + Assert.Null(env.Notice); + } + + private EnvFile Load(string contents) + { + var directory = NewDirectory(); + File.WriteAllText(Path.Combine(directory, ".env"), contents); + return ConnectionHelper.LoadEnvFile(directory); + } + + private string NewDirectory() + { + var directory = Path.Combine(Path.GetTempPath(), "planview-envfile-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + _directories.Add(directory); + return directory; + } + + public void Dispose() + { + foreach (var directory in _directories) + Directory.Delete(directory, recursive: true); + } +} From 2a575471fec4931807e351f8b6b0bf1cdb73e915 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:10:49 -0400 Subject: [PATCH 15/85] Take .env settings only when they have an effect, and reject control characters Review round 1 on #588: - A PLANVIEW_ value with a control character now stops the command with an error that names the key and the file, never the value. The CLI prints the server and database later, and an escape sequence there could erase the notice. The file path and keys in the notice and the error show control characters as '?'. - Both commands merge the file through one EnvFile.Fill method. With no server, analyze runs offline and takes nothing from the file. The file's password is used only with a login, so the notice lists only settings that had an effect. - PasswordResolver.TryResolve takes an optional writer for its messages, so the tests no longer swap Console.Error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- README.md | 7 + src/PlanViewer.Cli/Commands/AnalyzeCommand.cs | 16 +- .../Commands/QueryStoreCommand.cs | 16 +- src/PlanViewer.Cli/EnvFile.cs | 73 ++++-- src/PlanViewer.Cli/PasswordResolver.cs | 15 +- tests/PlanViewer.Core.Tests/EnvFileTests.cs | 229 ++++++++++++++---- 6 files changed, 277 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 4139a8da..253f4a6c 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,13 @@ When the `.env` file supplies a setting, the CLI prints one line to stderr. The 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 diff --git a/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs b/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs index 8f9ca944..7db5d988 100644 --- a/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs +++ b/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs @@ -155,17 +155,21 @@ public static Command Create(ICredentialService? credentialService = null) // Load .env file if present (CLI args take precedence) var env = ConnectionHelper.LoadEnvFile(); - server ??= env.Use("PLANVIEW_SERVER"); - database ??= env.Use("PLANVIEW_DATABASE"); - login ??= env.Use("PLANVIEW_LOGIN"); - if (!trustCert && env.UseFlag("PLANVIEW_TRUST_CERT")) - 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.Use("PLANVIEW_PASSWORD"), + () => env.PasswordFor(settings), out var password)) { Environment.ExitCode = 1; diff --git a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs index e511cb55..92f566f0 100644 --- a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs +++ b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs @@ -227,14 +227,22 @@ public static Command Create(ICredentialService? credentialService = null) // Load .env file if present (CLI args take precedence) var env = ConnectionHelper.LoadEnvFile(); - login ??= env.Use("PLANVIEW_LOGIN"); - if (!trustCert && env.UseFlag("PLANVIEW_TRUST_CERT")) - 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.Use("PLANVIEW_PASSWORD"), + () => env.PasswordFor(settings), out var password)) { Environment.ExitCode = 1; diff --git a/src/PlanViewer.Cli/EnvFile.cs b/src/PlanViewer.Cli/EnvFile.cs index 8234da51..12e68ccb 100644 --- a/src/PlanViewer.Cli/EnvFile.cs +++ b/src/PlanViewer.Cli/EnvFile.cs @@ -1,10 +1,14 @@ 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 a command asks for a value only when its option was 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. +/// 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 { @@ -15,13 +19,54 @@ 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; } - /// Returns the file's value for , or null, and records a hit. - public string? Use(string key) + /// 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; @@ -30,11 +75,8 @@ public EnvFile(string? filePath, Dictionary values) return value; } - /// - /// True when the file sets to "true". Only then is the key recorded, - /// because any other value leaves the setting as it was. - /// - public bool UseFlag(string key) + /* 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; @@ -43,16 +85,13 @@ public bool UseFlag(string key) return true; } - /// - /// 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 {FilePath}: {string.Join(", ", _used)}"; - 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 a7f137eb..843f1132 100644 --- a/src/PlanViewer.Cli/PasswordResolver.cs +++ b/src/PlanViewer.Cli/PasswordResolver.cs @@ -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, Func envPassword, - out string? password) + 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,7 +53,7 @@ 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, PLANVIEW_PASSWORD in a .env file, or the credential store."); password = inlinePassword; diff --git a/tests/PlanViewer.Core.Tests/EnvFileTests.cs b/tests/PlanViewer.Core.Tests/EnvFileTests.cs index 6299ea3b..9c681f53 100644 --- a/tests/PlanViewer.Core.Tests/EnvFileTests.cs +++ b/tests/PlanViewer.Core.Tests/EnvFileTests.cs @@ -4,94 +4,231 @@ namespace PlanViewer.Core.Tests; /// /// A .env file in the working directory can pick the server and turn off certificate validation. -/// The CLI names the file and the settings it supplied on stderr, and never their values. -/// -/// Shares a collection with because one test here swaps -/// Console.Error, as that class does. An uncaptured stderr write in this test host has aborted a whole -/// run before, so the two swaps must not interleave. +/// The CLI takes from the file only what the command line left out, names the file and the settings +/// it supplied on stderr, and never shows their values. /// -[Collection("EntraInteractiveAuth process-wide state")] public class EnvFileTests : IDisposable { + private const char Esc = (char)0x1B; + + private const string AllSettings = """ + # comment + PLANVIEW_SERVER=srv-in-file + PLANVIEW_DATABASE='db-in-file' + PLANVIEW_LOGIN="login-in-file" + PLANVIEW_TRUST_CERT=TRUE + PLANVIEW_PASSWORD=pw-in-file + """; + + private static readonly ConnectionSettings EmptyCommandLine = new(null, null, null, false); + private readonly List _directories = []; [Fact] - public void Notice_NamesTheFileAndOnlyTheSettingsItSupplied() + public void EverySettingFromTheFile_FillsTheGapsAndIsListedInOrder() + { + var env = Load(AllSettings); + + var settings = env.Fill(EmptyCommandLine); + + Assert.Equal(new ConnectionSettings("srv-in-file", "db-in-file", "login-in-file", true), settings); + Assert.Equal("pw-in-file", env.PasswordFor(settings)); + Assert.Equal( + $"Using settings from {env.FilePath}: PLANVIEW_SERVER, PLANVIEW_DATABASE, PLANVIEW_LOGIN, PLANVIEW_TRUST_CERT, PLANVIEW_PASSWORD", + env.Notice); + Assert.True(Path.IsPathFullyQualified(env.FilePath!)); + } + + [Theory] + [InlineData("PLANVIEW_SERVER")] + [InlineData("PLANVIEW_DATABASE")] + [InlineData("PLANVIEW_LOGIN")] + [InlineData("PLANVIEW_TRUST_CERT")] + public void AnOptionOnTheCommandLine_WinsAndItsKeyIsNotListed(string key) + { + var env = Load(AllSettings); + var commandLine = key switch + { + "PLANVIEW_SERVER" => EmptyCommandLine with { Server = "cli-server" }, + "PLANVIEW_DATABASE" => EmptyCommandLine with { Database = "cli-db" }, + "PLANVIEW_LOGIN" => EmptyCommandLine with { Login = "cli-login" }, + _ => EmptyCommandLine with { TrustCert = true }, + }; + + var settings = env.Fill(commandLine); + + Assert.Equal( + new ConnectionSettings( + commandLine.Server ?? "srv-in-file", + commandLine.Database ?? "db-in-file", + commandLine.Login ?? "login-in-file", + true), + settings); + + string[] keys = ["PLANVIEW_SERVER", "PLANVIEW_DATABASE", "PLANVIEW_LOGIN", "PLANVIEW_TRUST_CERT"]; + Assert.Equal($"Using settings from {env.FilePath}: {string.Join(", ", keys.Where(k => k != key))}", env.Notice); + } + + [Fact] + public void NoServer_TakesNothingFromTheFile() { var env = Load(""" - # comment - PLANVIEW_SERVER=elsewhere - PLANVIEW_DATABASE='db' - PLANVIEW_TRUST_CERT=TRUE + PLANVIEW_DATABASE=db-in-file + PLANVIEW_LOGIN=login-in-file + PLANVIEW_TRUST_CERT=true + PLANVIEW_PASSWORD=pw-in-file """); - // The command had --server, so it never asks the file for PLANVIEW_SERVER. - Assert.Equal("db", env.Use("PLANVIEW_DATABASE")); - Assert.True(env.UseFlag("PLANVIEW_TRUST_CERT")); + var settings = env.Fill(EmptyCommandLine); - Assert.Equal($"Using settings from {env.FilePath}: PLANVIEW_DATABASE, PLANVIEW_TRUST_CERT", env.Notice); - Assert.True(Path.IsPathFullyQualified(env.FilePath!)); + // With no server, analyze reads a plan file offline, so none of these would have an effect. + Assert.Equal(EmptyCommandLine, settings); + Assert.Null(env.PasswordFor(settings)); + Assert.Null(env.Notice); + } + + [Fact] + public void PasswordFromTheFile_IsUsedOnlyWithALogin() + { + var env = Load(""" + PLANVIEW_SERVER=srv-in-file + PLANVIEW_PASSWORD=pw-in-file + """); + + // Without a login the command uses the credential store or Windows authentication. + var settings = env.Fill(EmptyCommandLine); + Assert.Null(env.PasswordFor(settings)); + Assert.Null(env.PasswordFor(settings with { Login = "" })); + Assert.Equal($"Using settings from {env.FilePath}: PLANVIEW_SERVER", env.Notice); + + Assert.Equal("pw-in-file", env.PasswordFor(settings with { Login = "cli-login" })); + Assert.Equal($"Using settings from {env.FilePath}: PLANVIEW_SERVER, PLANVIEW_PASSWORD", env.Notice); + } + + [Fact] + public void PasswordFromTheFile_IsListedWhenNoOptionGaveOne() + { + var env = Load(AllSettings); + var settings = env.Fill(EmptyCommandLine); + var error = new StringWriter(); + + Assert.True(PasswordResolver.TryResolve(null, false, false, () => env.PasswordFor(settings), out var password, error)); + + Assert.Equal("pw-in-file", password); + Assert.EndsWith(", PLANVIEW_PASSWORD", env.Notice); + Assert.Empty(error.ToString()); + } + + [Fact] + public void PasswordFromTheFile_IsNotAskedForWhenAnOptionGaveOne() + { + var env = Load(AllSettings); + var settings = env.Fill(EmptyCommandLine); + var error = new StringWriter(); + + Assert.True(PasswordResolver.TryResolve("inline", false, false, () => env.PasswordFor(settings), out var password, error)); + + Assert.Equal("inline", password); + Assert.DoesNotContain("PLANVIEW_PASSWORD", env.Notice); + Assert.Contains("--password is visible", error.ToString()); } [Fact] public void Notice_NeverShowsAValue() { - var env = Load("PLANVIEW_PASSWORD=\"s3cret\""); + var env = Load(AllSettings); - Assert.Equal("s3cret", env.Use("PLANVIEW_PASSWORD")); - Assert.DoesNotContain("s3cret", env.Notice); - Assert.EndsWith(": PLANVIEW_PASSWORD", env.Notice); + env.PasswordFor(env.Fill(EmptyCommandLine)); + + Assert.NotNull(env.Notice); + foreach (var value in new[] { "srv-in-file", "db-in-file", "login-in-file", "pw-in-file" }) + Assert.DoesNotContain(value, env.Notice); } [Fact] - public void TrustCertOtherThanTrue_ChangesNothingAndIsNotReported() + public void TrustCertOtherThanTrue_ChangesNothingAndIsNotListed() { - var env = Load("PLANVIEW_TRUST_CERT=false"); + var env = Load(""" + PLANVIEW_SERVER=srv-in-file + PLANVIEW_TRUST_CERT=false + """); - Assert.False(env.UseFlag("PLANVIEW_TRUST_CERT")); - Assert.Null(env.Notice); + Assert.False(env.Fill(EmptyCommandLine).TrustCert); + Assert.Equal($"Using settings from {env.FilePath}: PLANVIEW_SERVER", env.Notice); } [Fact] public void NoEnvFile_SuppliesNothing() { var env = ConnectionHelper.LoadEnvFile(NewDirectory()); + var commandLine = EmptyCommandLine with { Server = "cli-server", Login = "cli-login" }; + + var settings = env.Fill(commandLine); Assert.Null(env.FilePath); - Assert.Null(env.Use("PLANVIEW_SERVER")); + Assert.Null(env.Error); + Assert.Equal(commandLine, settings); + Assert.Null(env.PasswordFor(settings)); Assert.Null(env.Notice); } + [Theory] + [InlineData(0x1B)] // ESC, which starts the sequences that move the cursor and erase lines + [InlineData(0x9B)] // CSI, the one-character form of ESC [ + [InlineData(0x07)] + [InlineData(0x00)] + [InlineData(0x7F)] + public void ControlCharacterInAValue_StopsTheCommandWithoutShowingTheValue(int code) + { + var env = Load($""" + PLANVIEW_SERVER=srv-in-file + PLANVIEW_DATABASE=db-in-file{(char)code}[1A + """); + + Assert.NotNull(env.Error); + Assert.Contains("PLANVIEW_DATABASE", env.Error); + Assert.Contains(env.FilePath!, env.Error); + Assert.DoesNotContain("db-in-file", env.Error); + Assert.DoesNotContain(env.Error, c => char.IsControl(c)); + } + [Fact] - public void PasswordFromTheFile_IsReportedWhenNoOptionGaveOne() + public void Tab_IsAllowed() { - var env = Load("PLANVIEW_PASSWORD=fromfile"); + var env = Load($"PLANVIEW_DATABASE=db{(char)0x09}in{(char)0x09}file"); - Assert.True(PasswordResolver.TryResolve(null, false, false, () => env.Use("PLANVIEW_PASSWORD"), out var password)); + Assert.Null(env.Error); + } + + [Fact] + public void ControlCharacterOutsideThePlanviewSettings_IsIgnored() + { + // A .env file often holds settings for other tools too. + var env = Load($""" + OTHER_TOOL_BANNER=hello{Esc}[1m + PLANVIEW_SERVER=srv-in-file + """); - Assert.Equal("fromfile", password); - Assert.EndsWith(": PLANVIEW_PASSWORD", env.Notice); + Assert.Null(env.Error); + Assert.Equal("srv-in-file", env.Fill(EmptyCommandLine).Server); } [Fact] - public void PasswordFromTheFile_IsNotAskedForWhenAnOptionGaveOne() + public void ControlCharacterInThePath_IsShownAsAQuestionMark() { - var env = Load("PLANVIEW_PASSWORD=fromfile"); + // Windows does not allow ESC in a directory name, so build the EnvFile directly. + var path = Path.Combine(Path.GetTempPath(), $"bad{Esc}[2Kdir", ".env"); - /* --password writes its process-listing warning to stderr, so capture the stream for the call. */ - var realError = Console.Error; - Console.SetError(new StringWriter()); - try - { - Assert.True(PasswordResolver.TryResolve("inline", false, false, () => env.Use("PLANVIEW_PASSWORD"), out var password)); - Assert.Equal("inline", password); - } - finally - { - Console.SetError(realError); - } + var env = new EnvFile(path, new(StringComparer.OrdinalIgnoreCase) { ["PLANVIEW_SERVER"] = "srv-in-file" }); + env.Fill(EmptyCommandLine); - Assert.Null(env.Notice); + Assert.Contains("bad?[2Kdir", env.Notice); + Assert.DoesNotContain(env.Notice!, c => char.IsControl(c)); + + var broken = new EnvFile(path, new(StringComparer.OrdinalIgnoreCase) { ["PLANVIEW_SERVER"] = $"srv{Esc}" }); + + Assert.Contains("bad?[2Kdir", broken.Error); + Assert.DoesNotContain(broken.Error!, c => char.IsControl(c)); } private EnvFile Load(string contents) @@ -103,7 +240,7 @@ private EnvFile Load(string contents) private string NewDirectory() { - var directory = Path.Combine(Path.GetTempPath(), "planview-envfile-" + Guid.NewGuid().ToString("N")); + var directory = Path.Combine(Path.GetTempPath(), "planview-envtest-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(directory); _directories.Add(directory); return directory; From 555d03ac629e375a04efe0cfb112215ea8968bfa Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:43:28 -0400 Subject: [PATCH 16/85] Parse deep plans on a large-stack thread (#589) A plan about 450 operators deep overflowed the 1 MB stack of the UI thread and the CLI's main thread before MaxParseDepth (1,000) could refuse it, and the process ended. Parse and ParseAsync now walk the tree on a 32 MB thread and join it, so the depth limit stops a deep plan. Same fix as PerformanceMonitor#4551. The web viewer (WebAssembly) still parses inline. ScopedDescendants and ResultMapper.MapNode use loops with their own stacks, and HtmlExporter keeps its per-node text out of the recursive method, so every step after the parse fits a 1 MB thread at the depth limit. JSON output, limited to about 500 operator levels, now says the plan is too deeply nested instead of "a possible object cycle" (CLI, Robot Advice, MCP, web share). The CLI stops with the parse error when a plan does not parse. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Controls/QuerySessionControl.Advice.cs | 4 +- src/PlanViewer.App/MainWindow.PlanViewer.cs | 4 +- src/PlanViewer.App/Mcp/McpHelpers.cs | 4 +- src/PlanViewer.Cli/Commands/AnalyzeCommand.cs | 22 +- .../Commands/PlanAnalysisRunner.cs | 30 ++- .../Commands/QueryStoreCommand.cs | 2 + src/PlanViewer.Core/Output/AnalysisJson.cs | 9 + src/PlanViewer.Core/Output/HtmlExporter.cs | 32 ++- src/PlanViewer.Core/Output/ResultMapper.cs | 27 +- .../Services/ShowPlanParser.Helpers.cs | 20 +- .../Services/ShowPlanParser.cs | 69 +++++- src/PlanViewer.Web/Pages/Index.razor | 7 +- .../DeepPlanStackTests.cs | 233 ++++++++++++++++++ 13 files changed, 433 insertions(+), 30 deletions(-) create mode 100644 tests/PlanViewer.Core.Tests/DeepPlanStackTests.cs 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/MainWindow.PlanViewer.cs b/src/PlanViewer.App/MainWindow.PlanViewer.cs index 34faebec..ef91e629 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; } 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.Cli/Commands/AnalyzeCommand.cs b/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs index 7db5d988..0d0f1509 100644 --- a/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs +++ b/src/PlanViewer.Cli/Commands/AnalyzeCommand.cs @@ -235,6 +235,13 @@ private static async Task RunAsync(FileInfo? file, bool stdin, string output, bo var plan = PlanAnalysisRunner.Analyze(planXml, analyzerConfig); + if (PlanAnalysisRunner.ParseFailure(plan) is { } parseFailure) + { + Console.Error.WriteLine(parseFailure); + Environment.ExitCode = 1; + return; + } + if (plan.Batches.Count == 0) { Console.Error.WriteLine("Could not parse any statements from the plan XML"); @@ -257,7 +264,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); } } @@ -432,6 +450,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/PlanAnalysisRunner.cs b/src/PlanViewer.Cli/Commands/PlanAnalysisRunner.cs index 46dce532..8b2a8c53 100644 --- a/src/PlanViewer.Cli/Commands/PlanAnalysisRunner.cs +++ b/src/PlanViewer.Cli/Commands/PlanAnalysisRunner.cs @@ -1,3 +1,4 @@ +using System; using System.IO; using System.Text.Json; using System.Threading.Tasks; @@ -22,6 +23,33 @@ 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, or null when it parsed. The + /// pipeline skips analysis for such a plan, and whatever parsed before the 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). + /// + public static string? ParseFailure(ParsedPlan plan) => + string.IsNullOrWhiteSpace(plan.ParseError) ? null : $"Could not parse the plan XML: {plan.ParseError}"; + + /// + /// 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); + } + } + /// /// Writes {label}.analysis.json and/or {label}.analysis.txt into outDir per /// outputFormat ("json", "text", or "both"), honoring warningsOnly (which @@ -39,7 +67,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 92f566f0..500aef20 100644 --- a/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs +++ b/src/PlanViewer.Cli/Commands/QueryStoreCommand.cs @@ -433,6 +433,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( 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/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index a174b590..c0b345a0 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Runtime.CompilerServices; using System.Text; using System.Web; @@ -530,6 +531,26 @@ private static void WriteWarnings(StringBuilder sb, StatementResult stmt) } private static void WriteOperatorNode(StringBuilder sb, OperatorResult node, StatementResult stmt) + { + WriteOperatorLine(sb, node); + + // Children + if (node.Children.Count > 0) + { + sb.AppendLine("
"); + foreach (var child in node.Children) + WriteOperatorNode(sb, child, stmt); + 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"; @@ -570,17 +591,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) diff --git a/src/PlanViewer.Core/Output/ResultMapper.cs b/src/PlanViewer.Core/Output/ResultMapper.cs index 4a9b3970..570dea38 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 @@ -369,10 +390,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/Services/ShowPlanParser.Helpers.cs b/src/PlanViewer.Core/Services/ShowPlanParser.Helpers.cs index e0ebf4b6..0908e4d6 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.Helpers.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.Helpers.cs @@ -46,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.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs index b2947539..0e3605b8 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs @@ -2,6 +2,8 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Runtime.ExceptionServices; +using System.Runtime.Versioning; using System.Xml; using System.Xml.Linq; using PlanViewer.Core.Models; @@ -19,9 +21,17 @@ public static partial class ShowPlanParser internal const int MaxParseDepth = 1000; internal const int MaxParseCharacters = 16 * 1024 * 1024; + /* #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 { /* Same ceiling ParseAsync enforces through XmlReaderSettings.MaxCharactersInDocument, @@ -32,7 +42,22 @@ which this synchronous path (PlanViewerControl, the web viewer, the analysis 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); + document = XDocument.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 + { + return RunOnParseThread(() => ParseDocument(document, plan, CancellationToken.None)); } catch (Exception exception) { @@ -61,7 +86,14 @@ internal static async Task ParseAsync( var document = await XDocument .LoadAsync(xmlReader, LoadOptions.None, cancellationToken) .ConfigureAwait(false); - return ParseDocument(document, plan, cancellationToken, beforeCostComputation); + + /* #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 +106,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, diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index f4878cdd..e0697fd3 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -359,7 +359,12 @@ else } catch (Exception ex) { - errorMessage = ex is PlanShareException ? ex.Message : $"Share failed: {ex.Message}"; + errorMessage = ex switch + { + PlanShareException => ex.Message, + System.Text.Json.JsonException => $"Share failed. {AnalysisJson.TooDeepMessage}", + _ => $"Share failed: {ex.Message}" + }; } finally { diff --git a/tests/PlanViewer.Core.Tests/DeepPlanStackTests.cs b/tests/PlanViewer.Core.Tests/DeepPlanStackTests.cs new file mode 100644 index 00000000..00f4d4f3 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/DeepPlanStackTests.cs @@ -0,0 +1,233 @@ +using System.Globalization; +using System.IO; +using System.Linq; +using System.Runtime.ExceptionServices; +using System.Text; +using System.Text.Json; +using System.Xml.Linq; +using PlanViewer.Core.Models; +using PlanViewer.Core.Output; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +/// +/// #589: the parser walked the plan tree on the caller's thread. On a 1 MB thread, the size of the +/// app's UI thread and the CLI's main thread, that walk overflowed the stack at about 450-480 +/// nested operators and ended the process, long before MaxParseDepth (1,000) could refuse the +/// plan. The walk now runs on its own large-stack thread. +/// +/// Every test here runs on a real 1 MB thread. A regression does not fail an assert: it +/// crashes the test host with a stack overflow, which is loud. The tests were run against the +/// unfixed code to confirm that. +/// +public sealed class DeepPlanStackTests +{ + private const int OneMegabyte = 1024 * 1024; + + /// + /// Nodes on the longest path of a plan at the depth limit: operator levels 0 through + /// MaxParseDepth, plus the statement's own node above them. + /// + private const int LevelsAtTheLimit = ShowPlanParser.MaxParseDepth + 2; + + [Fact] + public void APlanAtTheDepthLimitParsesOnAOneMegabyteThread() + { + var xml = NestedLoopsPlan(ShowPlanParser.MaxParseDepth); + + var plan = OnOneMegabyteThread(() => ShowPlanParser.Parse(xml)); + + Assert.Null(plan.ParseError); + Assert.Equal(LevelsAtTheLimit, TreeDepth(RootOf(plan))); + } + + [Fact] + public void OneLevelPastTheDepthLimitIsRefusedWithAParseError() + { + var xml = NestedLoopsPlan(ShowPlanParser.MaxParseDepth + 1); + + var plan = OnOneMegabyteThread(() => ShowPlanParser.Parse(xml)); + + Assert.NotNull(plan.ParseError); + Assert.Contains("depth limit", plan.ParseError); + } + + [Fact] + public void TheAsyncParseAlsoWalksOnItsOwnStack() + { + /* ParseAsync is what the analysis pipeline's async path calls. Without the parse thread, + its walk ran on whichever thread the XML load finished on: this one, or a thread-pool + thread, and neither holds 1,000 levels. */ + var xml = NestedLoopsPlan(ShowPlanParser.MaxParseDepth); + + var plan = OnOneMegabyteThread( + () => ShowPlanParser.ParseAsync(xml, CancellationToken.None).GetAwaiter().GetResult()); + + Assert.Null(plan.ParseError); + Assert.Equal(LevelsAtTheLimit, TreeDepth(RootOf(plan))); + } + + [Fact] + public void TheSearchInsideAnOperatorKeepsItsOwnStack() + { + /* No depth guard counts the elements inside an operator, and the recursive iterator that + searched them used stack for every level: 150,000 levels overflowed even the parse + thread's 32 MB. The search now keeps its own stack, so 20,000 levels, which needed + several MB before, fit in 256 KB. The tree is built directly, because XML that deep is + slow to load. */ + XNamespace showplan = Showplan; + var nested = new XElement(showplan + "W", new XElement(showplan + "Object")); + for (var level = 0; level < 20_000; level++) + nested = new XElement(showplan + "W", nested); + var hash = new XElement(showplan + "Hash", nested); + + var found = OnThread(256 * 1024, () => ShowPlanParser.ScopedDescendants(hash, showplan + "Object").ToList()); + + Assert.Single(found); + } + + [Fact] + public void EveryStepAfterTheParseRunsOnAOneMegabyteThread() + { + /* The parse thread covers only the parse. The steps after it walk the same 1,000-level + tree on the caller's thread, and the app and the CLI both call them on a 1 MB thread. + Measured at 1,000 levels in a child process, each step now needs 384 KB or less. Before + #589 changed them, the result mapper needed most of 1 MB and the HTML exporter 512 KB. */ + var xml = NestedLoopsPlan(ShowPlanParser.MaxParseDepth); + + OnOneMegabyteThread(() => + { + var plan = PlanAnalysisPipeline.Analyze(xml, new AnalyzerConfig()); + Assert.Null(plan.ParseError); + foreach (var statement in plan.Batches.SelectMany(batch => batch.Statements)) + PlanLayoutEngine.Layout(statement); + + var result = ResultMapper.Map(plan, "deep.sqlplan"); + Assert.Equal(LevelsAtTheLimit, TreeDepth(result.Statements.Single().OperatorTree!)); + var text = TextFormatter.Format(result); + Assert.NotEmpty(HtmlExporter.Export(result, text)); + + /* JSON stops at AnalysisJson.MaxDepth, about 500 operator levels, with an exception that + every caller turns into AnalysisJson.TooDeepMessage. */ + Assert.Throws(() => JsonSerializer.Serialize(result, AnalysisJson.Indented)); + return plan; + }); + } + + [Fact] + public void TheResultMapperKeepsItsOwnStack() + { + /* The mapper recursed once per operator level and needed more than 768 KB at 1,000 + levels. It now keeps its own stack, so the whole tree maps on a 256 KB thread. */ + var xml = NestedLoopsPlan(ShowPlanParser.MaxParseDepth); + + var result = OnThread(256 * 1024, () => ResultMapper.Map(ShowPlanParser.Parse(xml), "deep.sqlplan")); + + Assert.Equal(LevelsAtTheLimit, TreeDepth(result.Statements.Single().OperatorTree!)); + } + + [Fact] + public void TheParseThreadUsesTheCallersCulture() + { + /* The parser writes some warning text itself, such as a spill's granted memory, in the + current culture. The caller's culture reaches the parse thread with the execution + context; this fails if the thread is ever started without it. */ + var xml = File.ReadAllText(Path.Combine("Plans", "spill_plan.sqlplan")); + + var plan = OnOneMegabyteThread(() => + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE"); + return ShowPlanParser.Parse(xml); + }); + + Assert.Null(plan.ParseError); + var spill = PlanTestHelper.AllWarnings(plan).First(warning => warning.Message.Contains("Granted:")); + Assert.Contains("Granted: 10.815.552 KB", spill.Message); + } + + /// + /// Runs on a new thread with the given stack and rethrows anything it + /// throws, so an assert inside it fails the test instead of ending the process. + /// + private static T OnOneMegabyteThread(Func work) => OnThread(OneMegabyte, work); + + private static T OnThread(int stackBytes, Func work) + { + T result = default!; + Exception? failure = null; + var thread = new Thread(() => + { + try + { + result = work(); + } + catch (Exception exception) + { + failure = exception; + } + }, stackBytes); + thread.Start(); + thread.Join(); + if (failure is not null) + ExceptionDispatchInfo.Capture(failure).Throw(); + return result; + } + + private static PlanNode RootOf(ParsedPlan plan) => + Assert.Single(Assert.Single(plan.Batches).Statements).RootNode!; + + private static int TreeDepth(PlanNode root) => + Deepest(root, node => node.Children); + + private static int TreeDepth(OperatorResult root) => + Deepest(root, node => node.Children); + + private static int Deepest(TNode root, Func> children) + { + var deepest = 0; + var pending = new Stack<(TNode Node, int Depth)>(); + pending.Push((root, 1)); + while (pending.Count > 0) + { + var (node, depth) = pending.Pop(); + deepest = Math.Max(deepest, depth); + foreach (var child in children(node)) + pending.Push((child, depth + 1)); + } + return deepest; + } + + private const string Showplan = "http://schemas.microsoft.com/sqlserver/2004/07/showplan"; + + /// + /// An actual plan with nested Nested Loops, each with a Constant + /// Scan on its inner side, above a Constant Scan. Runtime counters on every operator make the + /// analyzer's timing rules walk the whole tree too. + /// + private static string NestedLoopsPlan(int levels) + { + var xml = new StringBuilder(); + xml.Append($""); + xml.Append(""); + xml.Append(""); + var nodeId = 0; + for (var level = 0; level < levels; level++) + { + var elapsed = 5000 - level * 4; + xml.Append($""); + xml.Append($""); + xml.Append(""); + } + xml.Append(ConstantScan(nodeId++)); + for (var level = 0; level < levels; level++) + xml.Append(ConstantScan(nodeId++)).Append(""); + xml.Append(""); + return xml.ToString(); + } + + private static string ConstantScan(int nodeId) => + $"" + + "" + + ""; +} From 5049cbdc12949132c2ee9430ce6a2eb301dba3e3 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:57:43 -0400 Subject: [PATCH 17/85] Repro script: keep @0/@1 parameters, check data types by shape (#590) Simple and forced parameterization name their parameters @0, @1, ..., and IsValidParameterName required a letter after the @. Those parameters were dropped, so the script ran the statement without declaring them and failed with "Must declare the scalar variable". - IsValidParameterName allows a digit after the @. - The name, type and literal checks use \A...\z instead of ^...$, because $ also matches before a final line break. - IsValidDataType checks the shape of the type (1-3 dot-separated names, then an optional (n), (max), (p,s) or (n,name) suffix) instead of a character list. Same idea as PerformanceMonitor#4567, but it also keeps vector(3,float16), which SQL Server 2025 writes into plans, and it allows only plain spaces, not line breaks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Services/ReproScriptBuilder.cs | 32 ++++-- .../ReproScriptBuilderSafetyTests.cs | 98 +++++++++++++++++++ 2 files changed, 120 insertions(+), 10 deletions(-) diff --git a/src/PlanViewer.Core/Services/ReproScriptBuilder.cs b/src/PlanViewer.Core/Services/ReproScriptBuilder.cs index a8c1a7c7..10ea64af 100644 --- a/src/PlanViewer.Core/Services/ReproScriptBuilder.cs +++ b/src/PlanViewer.Core/Services/ReproScriptBuilder.cs @@ -426,22 +426,32 @@ private static string CommentSafe(string? text) } /// - /// Validates a parameter name from plan XML as a plain @identifier. - /// Anything else is dropped from the generated script. + /// 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. + /// Only plain spaces are allowed between the parts: no quotes, comment + /// characters or line breaks, 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}}(?: *\( *(?:\d+|max) *(?:, *(?:\d+|{name}) *)?\))?\z", + RegexOptions.IgnoreCase); } /// @@ -454,16 +464,18 @@ private static bool IsSafeLiteral(string value) if (value.Equals("NULL", StringComparison.OrdinalIgnoreCase)) return true; + /* \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 */ - if (Regex.IsMatch(value, @"^-?\$?\d+(\.\d+)?([eE][+-]?\d+)?$")) + if (Regex.IsMatch(value, @"\A-?\$?\d+(\.\d+)?([eE][+-]?\d+)?\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/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs b/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs index 6ddf472c..96013ae6 100644 --- a/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs +++ b/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs @@ -105,6 +105,104 @@ public void BuildReproScript_DroppedParameter_IsReportedInWarnings() Assert.Contains("1 parameter(s) omitted", sql); } + [Fact] + public void BuildReproScript_AutoParameterizedNames_AreDeclaredAndAssigned() + { + /* #590: simple and forced parameterization name their parameters @0, @1, ... . + These were dropped, so the script ran the statement without declaring them and + failed with "Must declare the scalar variable". The ParameterList is copied from + a forced-parameterization plan on SQL Server 2025, in its order. */ + const string plan = """ + + + + + + + + + + + + + """; + var sql = ReproScriptBuilder.BuildReproScript( + "(@0 nvarchar(4000),@1 int)select t . id from dbo . T as t where t . v = @0 and t . id > @1", + "db", plan, null); + + Assert.Contains("N'@1 int, @0 nvarchar(4000)'", sql); + Assert.Contains("@1 = 0", sql); + Assert.Contains("@0 = N'b'", sql); + Assert.DoesNotContain("omitted", sql); + ParseScript(sql); + } + + [Fact] + public void BuildReproScript_ParameterNameEndingInALineBreak_IsDropped() + { + /* The XML parser turns a line break in an attribute into a space unless it is written + as a character reference. ^...$ let this name through, because $ also matches + before a final line break. */ + var plan = PlanWithParameter("@id ", "int", "(1)"); + var sql = ReproScriptBuilder.BuildReproScript("SELECT 1", "db", plan, null); + + Assert.Contains("1 parameter(s) omitted", sql); + } + + [Fact] + public void BuildReproScript_CompiledValueEndingInALineBreak_BecomesPlaceholder() + { + var plan = PlanWithParameter("@id", "int", "42 "); + var sql = ReproScriptBuilder.BuildReproScript("SELECT 1", "db", plan, null); + + Assert.Contains("@id = ?", sql); + } + + [Theory] + [InlineData("tinyint")] + [InlineData("decimal(18,2)")] + [InlineData("nvarchar(max)")] + [InlineData("nvarchar(4000)")] + [InlineData("datetime2(7)")] + [InlineData("datetimeoffset(3)")] + [InlineData("time(0)")] + [InlineData("sys.geography")] + [InlineData("sys.hierarchyid")] + [InlineData("sql_variant")] + [InlineData("xml")] + [InlineData("json")] + [InlineData("vector(3)")] + [InlineData("vector(3,float16)")] + [InlineData("[dbo].[Amount]")] + public void BuildReproScript_DataTypesFromRealPlans_AreKept(string dataType) + { + /* Every type here but the last is a ParameterDataType that SQL Server 2025 wrote into + a plan. An alias type shows up as its base type, and a typed xml parameter as xml. + No plan here used brackets, but the check before #590 allowed them, so this keeps + them working. */ + var plan = PlanWithParameter("@p", dataType, "NULL"); + var sql = ReproScriptBuilder.BuildReproScript("SELECT @p", "db", plan, null); + + Assert.Contains($"N'@p {dataType}'", sql); + Assert.DoesNotContain("omitted", sql); + } + + [Theory] + [InlineData("int) SELECT 2 SELECT (1")] // text after the closing paren + [InlineData("decimal(18,2")] // no closing paren + [InlineData("int ")] // ends in a line break + [InlineData("a.b.c.d")] // four-part name + [InlineData("vector(3, GO )")] // GO on a line of its own + public void BuildReproScript_MalformedDataType_IsDropped(string dataType) + { + /* The check before #590 was a list of characters, and the first four passed it. */ + var plan = PlanWithParameter("@id", dataType, "(1)"); + var sql = ReproScriptBuilder.BuildReproScript("SELECT 1", "db", plan, null); + + Assert.Contains("1 parameter(s) omitted", sql); + Assert.DoesNotContain("SELECT 2", sql); + } + [Theory] [InlineData("int", "(42)", "42")] // parenthesized integer [InlineData("int", "(-7)", "-7")] // negative From 6f66ff81ee8377ccdf189f6710c07572f0063d07 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:10:09 -0400 Subject: [PATCH 18/85] Review fixes for #589: Query Store MCP parse errors, share errors, tests - get_query_store_top reports a plan the parser refuses in load_error. It returned the plan as loaded, with no warnings. - The web viewer's share button maps only the serializer's JsonException to TooDeepMessage. A reply from the server that isn't JSON gets the usual "Share failed" message again. - HtmlExporter.WriteOperatorNode drops a parameter it never used. - New tests: the HTML exporter at 1,000 levels on a 384 KB thread (the old exporter overflows there), the search's document order and RelOp skipping, the CLI's parse-failure message, and the too-deep messages from the CLI and the MCP tools. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- src/PlanViewer.App/Mcp/McpQueryStoreTools.cs | 5 + src/PlanViewer.Core/Output/HtmlExporter.cs | 6 +- src/PlanViewer.Web/Pages/Index.razor | 7 +- .../Services/PlanShareService.cs | 21 +++- .../DeepPlanStackTests.cs | 100 +++++++++++++++++- 5 files changed, 122 insertions(+), 17 deletions(-) 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.Core/Output/HtmlExporter.cs b/src/PlanViewer.Core/Output/HtmlExporter.cs index c0b345a0..33b1cff5 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -292,7 +292,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("
"); } @@ -530,7 +530,7 @@ 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); @@ -539,7 +539,7 @@ private static void WriteOperatorNode(StringBuilder sb, OperatorResult node, Sta { sb.AppendLine("
"); foreach (var child in node.Children) - WriteOperatorNode(sb, child, stmt); + WriteOperatorNode(sb, child); sb.AppendLine("
"); } diff --git a/src/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index e0697fd3..f4878cdd 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -359,12 +359,7 @@ else } catch (Exception ex) { - errorMessage = ex switch - { - PlanShareException => ex.Message, - System.Text.Json.JsonException => $"Share failed. {AnalysisJson.TooDeepMessage}", - _ => $"Share failed: {ex.Message}" - }; + errorMessage = ex is PlanShareException ? ex.Message : $"Share failed: {ex.Message}"; } finally { diff --git a/src/PlanViewer.Web/Services/PlanShareService.cs b/src/PlanViewer.Web/Services/PlanShareService.cs index e25d511a..5a3794e7 100644 --- a/src/PlanViewer.Web/Services/PlanShareService.cs +++ b/src/PlanViewer.Web/Services/PlanShareService.cs @@ -50,12 +50,23 @@ public async Task ShareAsync(AnalysisResult result, string text depth ceiling exists for "every writer of this object". Default MaxDepth is 64, an operator costs two JSON levels, so sharing a plan ~30 operators deep threw an "object cycle" JsonException here while the same analysis rendered fine everywhere else. */ - var payload = JsonSerializer.Serialize(new + string payload; + try { - result = result, - text = text, - ttl_days = ttlDays - }, AnalysisJson.Wire); + payload = JsonSerializer.Serialize(new + { + result = result, + text = text, + ttl_days = ttlDays + }, AnalysisJson.Wire); + } + catch (JsonException) + { + /* #589: an analysis has no cycles, so the "object cycle" JsonException here is the + depth limit. Only this call maps to TooDeepMessage: a reply that isn't JSON also + throws JsonException below, and that has nothing to do with depth. */ + throw new PlanShareException($"Share failed. {AnalysisJson.TooDeepMessage}"); + } var content = new StringContent(payload, Encoding.UTF8, "application/json"); var response = await _http.PostAsync($"{ApiBase}/api/share", content); diff --git a/tests/PlanViewer.Core.Tests/DeepPlanStackTests.cs b/tests/PlanViewer.Core.Tests/DeepPlanStackTests.cs index 00f4d4f3..622f466a 100644 --- a/tests/PlanViewer.Core.Tests/DeepPlanStackTests.cs +++ b/tests/PlanViewer.Core.Tests/DeepPlanStackTests.cs @@ -5,6 +5,8 @@ using System.Text; using System.Text.Json; using System.Xml.Linq; +using PlanViewer.App.Mcp; +using PlanViewer.Cli.Commands; using PlanViewer.Core.Models; using PlanViewer.Core.Output; using PlanViewer.Core.Services; @@ -17,9 +19,9 @@ namespace PlanViewer.Core.Tests; /// nested operators and ended the process, long before MaxParseDepth (1,000) could refuse the /// plan. The walk now runs on its own large-stack thread. /// -/// Every test here runs on a real 1 MB thread. A regression does not fail an assert: it -/// crashes the test host with a stack overflow, which is loud. The tests were run against the -/// unfixed code to confirm that. +/// The depth tests run on real threads with small stacks: 1 MB, or less where a step +/// should need less. A regression there does not fail an assert: it crashes the test host with a +/// stack overflow, which is loud. The tests were run against the unfixed code to confirm that. ///
public sealed class DeepPlanStackTests { @@ -127,6 +129,81 @@ public void TheResultMapperKeepsItsOwnStack() Assert.Equal(LevelsAtTheLimit, TreeDepth(result.Statements.Single().OperatorTree!)); } + [Fact] + public void TheHtmlExportersRecursionKeepsASmallFrame() + { + /* Each operator's line is written in a separate method, so the recursive method's frame + stays small. Measured at 1,000 levels in a child process: the exporter now runs in + 256 KB in Release and 320 KB in Debug. Before #589 it needed more than 384 KB in + Release and more than 640 KB in Debug, so this crashes if the split is undone. */ + var xml = NestedLoopsPlan(ShowPlanParser.MaxParseDepth); + var (result, text) = OnOneMegabyteThread(() => + { + var mapped = ResultMapper.Map(ShowPlanParser.Parse(xml), "deep.sqlplan"); + return (mapped, TextFormatter.Format(mapped)); + }); + + var html = OnThread(384 * 1024, () => HtmlExporter.Export(result, text)); + + var operatorsWritten = html.Split("
element.Attribute("Id")!.Value); + + Assert.Equal(new[] { "1", "2", "3", "4" }, found); + } + + [Fact] + public void TheCliStopsWithTheParseError() + { + /* Before #589, the CLI's live path and query-store command wrote an empty analysis for a + plan that did not parse, and reported OK. */ + var refused = ShowPlanParser.Parse(NestedLoopsPlan(ShowPlanParser.MaxParseDepth + 1)); + var parsed = ShowPlanParser.Parse(NestedLoopsPlan(3)); + + Assert.Equal($"Could not parse the plan XML: {refused.ParseError}", PlanAnalysisRunner.ParseFailure(refused)); + Assert.Null(PlanAnalysisRunner.ParseFailure(parsed)); + } + + [Fact] + public void JsonPastTheDepthLimitReportsTheRealCause() + { + /* Past about 500 operator levels the serializer's own message blames "a possible object + cycle". The CLI and the MCP tools say what is really wrong. */ + var xml = NestedLoopsPlan(600); + + OnOneMegabyteThread(() => + { + var result = ResultMapper.Map(ShowPlanParser.Parse(xml), "deep.sqlplan"); + + var cli = Assert.Throws( + () => PlanAnalysisRunner.SerializeResult(result, AnalysisJson.Indented)); + Assert.StartsWith(AnalysisJson.TooDeepMessage, cli.Message); + Assert.IsType(cli.InnerException); + + var tooDeep = Assert.Throws(() => JsonSerializer.Serialize(result, AnalysisJson.Indented)); + Assert.Equal($"Error during analyze_plan: {AnalysisJson.TooDeepMessage}", McpHelpers.FormatError("analyze_plan", tooDeep)); + Assert.Equal("Error during analyze_plan: boom", McpHelpers.FormatError("analyze_plan", new InvalidOperationException("boom"))); + return result; + }); + } + [Fact] public void TheParseThreadUsesTheCallersCulture() { @@ -198,6 +275,23 @@ private static int Deepest(TNode root, Func> ch return deepest; } + private static int NodeCount(OperatorResult root) + { + var count = 0; + var pending = new Stack(); + pending.Push(root); + while (pending.Count > 0) + { + count++; + foreach (var child in pending.Pop().Children) + pending.Push(child); + } + return count; + } + + private static XElement ObjectElement(string id) => + new(XName.Get("Object", Showplan), new XAttribute("Id", id)); + private const string Showplan = "http://schemas.microsoft.com/sqlserver/2004/07/showplan"; /// From 940ff21b80b3440fe270650d28372f01cff6a15b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:22:12 -0400 Subject: [PATCH 19/85] Review fixes for #590: declare each parameter once, ASCII digits only - A batch's plan lists each statement's parameters, so a name can appear more than once. Keeping @0/@1 made that common: each auto-parameterized statement numbers its own, and the script declared @1 twice and failed with Msg 134. A parameter that several statements use (for example a shared @id) had the same problem before #590. - Each name is now declared once. - A name that the statements give different types is left out with a warning. Those statements are usually literal text that doesn't use it. - The type check's numbers and the compiled-value check use [0-9], not \d, which also matches other scripts' digits. (max) takes no second part. - Tests for both multi-statement cases, the digit checks, and the omitted warning for a hostile name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Services/ReproScriptBuilder.cs | 37 ++++++++-- .../ReproScriptBuilderSafetyTests.cs | 74 ++++++++++++++++++- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/src/PlanViewer.Core/Services/ReproScriptBuilder.cs b/src/PlanViewer.Core/Services/ReproScriptBuilder.cs index 10ea64af..f893ecf5 100644 --- a/src/PlanViewer.Core/Services/ReproScriptBuilder.cs +++ b/src/PlanViewer.Core/Services/ReproScriptBuilder.cs @@ -63,10 +63,26 @@ 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 conflictingNames = validParameters + .GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase) + .Where(g => g.Select(p => p.DataType).Distinct(StringComparer.OrdinalIgnoreCase).Count() > 1) + .Select(g => g.Key) + .ToList(); + var safeParameters = validParameters + .Where(p => !conflictingNames.Contains(p.Name, StringComparer.OrdinalIgnoreCase)) + .DistinctBy(p => p.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + /* Check for temp tables and table variables in query text */ var tempTableWarnings = DetectTempTablesAndTableVariables(queryText); warnings.AddRange(tempTableWarnings); @@ -90,12 +106,17 @@ 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."); + } + /* Check for local variables: query has parameter prefix but plan has no/few parameters */ var trimmedQuery = queryText.Trim(); var cleanedQuery = StripParameterPrefix(trimmedQuery); @@ -442,15 +463,16 @@ private static bool IsValidParameterName(string name) /// (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. - /// Only plain spaces are allowed between the parts: no quotes, comment - /// characters or line breaks, so it can't disturb the sp_executesql declaration list. + /// 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) { const string name = @"(?:\[[\p{L}\p{Nd}_ ]+\]|[\p{L}_][\p{L}\p{Nd}_]*)"; return Regex.IsMatch( dataType, - $@"\A{name}(?:\.{name}){{0,2}}(?: *\( *(?:\d+|max) *(?:, *(?:\d+|{name}) *)?\))?\z", + $@"\A{name}(?:\.{name}){{0,2}}(?: *\( *(?:max|[0-9]+(?: *, *(?:[0-9]+|{name}))?) *\))?\z", RegexOptions.IgnoreCase); } @@ -466,8 +488,9 @@ private static bool IsSafeLiteral(string value) /* \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 */ - if (Regex.IsMatch(value, @"\A-?\$?\d+(\.\d+)?([eE][+-]?\d+)?\z")) + /* 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 */ diff --git a/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs b/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs index 96013ae6..80867189 100644 --- a/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs +++ b/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs @@ -73,6 +73,7 @@ public void BuildReproScript_HostileParameterName_IsDroppedEntirely() var sql = ReproScriptBuilder.BuildReproScript("SELECT 1", "db", plan, null); Assert.DoesNotContain("SHUTDOWN", sql); + Assert.Contains("1 parameter(s) omitted", sql); } [Fact] @@ -137,6 +138,63 @@ failed with "Must declare the scalar variable". The ParameterList is copied from ParseScript(sql); } + [Fact] + public void BuildReproScript_ParameterUsedByTwoStatements_IsDeclaredOnce() + { + /* A batch's plan lists each statement's parameters, so a parameter that two statements + use appears twice. Declaring it twice fails with "The variable name '@id' has already + been declared". The ParameterLists are copied from such a plan on SQL Server 2025. */ + const string plan = """ + + + + + + + + + + + """; + var sql = ReproScriptBuilder.BuildReproScript( + "(@id int)SELECT COUNT_BIG(*) AS a FROM dbo.T AS t WHERE t.id = @id\n; SELECT COUNT_BIG(*) AS b FROM dbo.T AS t WHERE t.id > @id", + "db", plan, null); + + Assert.Contains("N'@id int',", sql); + Assert.Equal(1, sql.Split("@id = 1").Length - 1); + ParseScript(sql); + } + + [Fact] + public void BuildReproScript_AutoParameterTypedDifferentlyByTwoStatements_IsLeftOut() + { + /* Each auto-parameterized statement numbers its own parameters and types them by its + literal, so a batch's estimated plan can list @1 smallint and @1 tinyint. Its + statement text is the literal text, which doesn't use @1, so the script runs the + batch as it is. The ParameterLists are copied from such a plan on SQL Server 2025. */ + const string plan = """ + + + + + + + + + + + """; + var sql = ReproScriptBuilder.BuildReproScript( + "SELECT t.id FROM dbo.T AS t WHERE t.id = 22656\n; SELECT t.v FROM dbo.T AS t WHERE t.id = 11", + "db", plan, null); + + Assert.DoesNotContain("EXECUTE sys.sp_executesql", sql); + Assert.Contains("different data type in different statements (left out): @1.", sql); + Assert.Contains("SELECT t.v FROM dbo.T AS t WHERE t.id = 11", sql); + Assert.DoesNotContain("omitted", sql); + ParseScript(sql); + } + [Fact] public void BuildReproScript_ParameterNameEndingInALineBreak_IsDropped() { @@ -158,6 +216,16 @@ public void BuildReproScript_CompiledValueEndingInALineBreak_BecomesPlaceholder( Assert.Contains("@id = ?", sql); } + [Fact] + public void BuildReproScript_CompiledValueInAnotherScriptsDigits_BecomesPlaceholder() + { + /* \d matches these Arabic-Indic digits, but T-SQL doesn't read them as a number. */ + var plan = PlanWithParameter("@id", "int", "٤٢"); + var sql = ReproScriptBuilder.BuildReproScript("SELECT 1", "db", plan, null); + + Assert.Contains("@id = ?", sql); + } + [Theory] [InlineData("tinyint")] [InlineData("decimal(18,2)")] @@ -193,9 +261,13 @@ them working. */ [InlineData("int ")] // ends in a line break [InlineData("a.b.c.d")] // four-part name [InlineData("vector(3, GO )")] // GO on a line of its own + [InlineData("nvarchar(٤)")] // an Arabic-Indic digit + [InlineData("nvarchar(4)")] // a full-width digit + [InlineData("varchar(max,2)")] // max takes no second part public void BuildReproScript_MalformedDataType_IsDropped(string dataType) { - /* The check before #590 was a list of characters, and the first four passed it. */ + /* The check before #590 was a list of characters, and it passed every one of these but + the one with GO. */ var plan = PlanWithParameter("@id", dataType, "(1)"); var sql = ReproScriptBuilder.BuildReproScript("SELECT 1", "db", plan, null); From 68087ed0d977d92e9c174c8608446c8759cf17e2 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:35:44 -0400 Subject: [PATCH 20/85] Repro script: use a later statement's value when the first has none Review round 2 for #590. A parameter listed by several statements now takes the first compiled value that can go into the script, not the first entry, so a statement compiled without sniffing no longer sets it to ?. When the statements disagree, the script uses the first usable value and says so. When every parameter is left out, the script says to see the warnings instead of claiming the plan cache had none. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Services/ReproScriptBuilder.cs | 32 +++++++++-- .../ReproScriptBuilderSafetyTests.cs | 57 +++++++++++++++++++ 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/src/PlanViewer.Core/Services/ReproScriptBuilder.cs b/src/PlanViewer.Core/Services/ReproScriptBuilder.cs index f893ecf5..82136fe3 100644 --- a/src/PlanViewer.Core/Services/ReproScriptBuilder.cs +++ b/src/PlanViewer.Core/Services/ReproScriptBuilder.cs @@ -73,14 +73,26 @@ the warning comment and the sp_executesql assignment list. */ 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 conflictingNames = validParameters + 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 safeParameters = validParameters - .Where(p => !conflictingNames.Contains(p.Name, StringComparer.OrdinalIgnoreCase)) - .DistinctBy(p => p.Name, StringComparer.OrdinalIgnoreCase) + 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 */ @@ -117,6 +129,11 @@ rather than leaving an unexplained placeholder. */ 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); @@ -211,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(';')) { diff --git a/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs b/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs index 80867189..377121fd 100644 --- a/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs +++ b/tests/PlanViewer.Core.Tests/ReproScriptBuilderSafetyTests.cs @@ -192,6 +192,63 @@ batch as it is. The ParameterLists are copied from such a plan on SQL Server 202 Assert.Contains("different data type in different statements (left out): @1.", sql); Assert.Contains("SELECT t.v FROM dbo.T AS t WHERE t.id = 11", sql); Assert.DoesNotContain("omitted", sql); + Assert.DoesNotContain("No parameters found in plan cache", sql); + Assert.Contains("/* No parameters declared: see the warnings above */", sql); + ParseScript(sql); + } + + [Fact] + public void BuildReproScript_ParameterWithAValueOnlyInTheSecondStatement_UsesThatValue() + { + /* A statement compiled without sniffing lists the parameter with no compiled value. + Taking the first entry would set @id to ? although another statement has a value. */ + const string plan = """ + + + + + + + + + + + """; + var sql = ReproScriptBuilder.BuildReproScript( + "(@id int)SELECT COUNT_BIG(*) AS a FROM dbo.T AS t WHERE t.id = @id\n; SELECT COUNT_BIG(*) AS b FROM dbo.T AS t WHERE t.id > @id", + "db", plan, null); + + Assert.Contains("N'@id int',", sql); + Assert.Contains("@id = 5", sql); + Assert.DoesNotContain("missing values", sql); + Assert.DoesNotContain("different compiled value", sql); + ParseScript(sql); + } + + [Fact] + public void BuildReproScript_ParameterWithDifferentValuesInTwoStatements_UsesTheFirstAndWarns() + { + /* Statements recompiled at different times sniff different values. The script can + only set one, so it uses the first and says the statements disagree. */ + const string plan = """ + + + + + + + + + + + """; + var sql = ReproScriptBuilder.BuildReproScript( + "(@id int)SELECT COUNT_BIG(*) AS a FROM dbo.T AS t WHERE t.id = @id\n; SELECT COUNT_BIG(*) AS b FROM dbo.T AS t WHERE t.id > @id", + "db", plan, null); + + Assert.Contains("@id = 1", sql); + Assert.DoesNotContain("@id = 2", sql); + Assert.Contains("different compiled value in different statements (set to the first usable one): @id.", sql); ParseScript(sql); } From d9aa6bb959f6b163f0804431d147f59c8ba693ce Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:17:22 -0400 Subject: [PATCH 21/85] Harden plan loading: linear-time analyzer matching, one XML loader Analyzer: - The bracket, quote, LIKE and cursor patterns use RegexOptions.NonBacktracking, which finds the same matches in time that grows in step with the text. - DetectNonSargablePattern finds a predicate's AND/OR operators once and reads each side of a comparison once, instead of once per function call. - ConvertImplicitWrapsColumn skips a conversion nested inside one it already read. XML: - New PlanXml loader used at every site that parsed plan XML: DTDs refused, no resolver, the existing 16M-character limit, a nesting depth limit of 8,192 and a limit on the sum of all nodes' depths, which XDocument's load time grows with. - Linked into the web project. Tests: loader limits, six inputs that took 30 s or more before, and three checks that nested conversions and two-sided comparisons give the same answers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Controls/QuerySessionControl.Execution.cs | 2 +- src/PlanViewer.App/MainWindow.FileOps.cs | 2 +- src/PlanViewer.App/MainWindow.PlanViewer.cs | 2 +- .../Services/EstimatedPlanExecutor.cs | 4 +- .../Services/PlanAnalyzer.Detection.cs | 190 ++++++++++++------ .../Services/PlanAnalyzer.Statement.cs | 4 +- src/PlanViewer.Core/Services/PlanAnalyzer.cs | 18 +- src/PlanViewer.Core/Services/PlanXml.cs | 89 ++++++++ .../Services/ReproScriptBuilder.cs | 4 +- .../Services/ShowPlanParser.cs | 29 +-- src/PlanViewer.Web/PlanViewer.Web.csproj | 1 + .../LongPredicateTests.cs | 80 ++++++++ .../PlanAnalyzerTests.cs | 29 +++ tests/PlanViewer.Core.Tests/PlanXmlTests.cs | 90 +++++++++ 14 files changed, 442 insertions(+), 102 deletions(-) create mode 100644 src/PlanViewer.Core/Services/PlanXml.cs create mode 100644 tests/PlanViewer.Core.Tests/LongPredicateTests.cs create mode 100644 tests/PlanViewer.Core.Tests/PlanXmlTests.cs diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs index 71bb358f..2d5c7fea 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs @@ -427,7 +427,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/MainWindow.FileOps.cs b/src/PlanViewer.App/MainWindow.FileOps.cs index 24367394..dceaf8a3 100644 --- a/src/PlanViewer.App/MainWindow.FileOps.cs +++ b/src/PlanViewer.App/MainWindow.FileOps.cs @@ -483,7 +483,7 @@ private bool ValidatePlanXml(string xml, string label) { try { - var doc = XDocument.Parse(xml); + var doc = PlanXml.Parse(xml); XNamespace ns = "http://schemas.microsoft.com/sqlserver/2004/07/showplan"; if (doc.Root?.Name.LocalName != "ShowPlanXML" && doc.Descendants(ns + "ShowPlanXML").FirstOrDefault() == null) diff --git a/src/PlanViewer.App/MainWindow.PlanViewer.cs b/src/PlanViewer.App/MainWindow.PlanViewer.cs index ef91e629..97218fac 100644 --- a/src/PlanViewer.App/MainWindow.PlanViewer.cs +++ b/src/PlanViewer.App/MainWindow.PlanViewer.cs @@ -407,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; 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/PlanAnalyzer.Detection.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs index 266a7bad..f59c220f 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs @@ -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,27 @@ 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 nested inside one already read: its arguments are part of that one's, + // which held no column. 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 +523,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.Statement.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs index 830be28c..53f82fd2 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Statement.cs @@ -390,10 +390,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( 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; diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.cs index eb42bb24..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); diff --git a/src/PlanViewer.Core/Services/PlanXml.cs b/src/PlanViewer.Core/Services/PlanXml.cs new file mode 100644 index 00000000..c2e1ff5c --- /dev/null +++ b/src/PlanViewer.Core/Services/PlanXml.cs @@ -0,0 +1,89 @@ +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; + + 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 or too deeply nested. + /// + private static void CheckLimits(string xml) + { + if (xml.Length > MaxCharacters) + throw new XmlException( + $"Plan XML exceeds the supported size limit of {MaxCharacters.ToString("N0", CultureInfo.InvariantCulture)} characters."); + + 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."); + } + } + + /// + /// 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 82136fe3..a7c92298 100644 --- a/src/PlanViewer.Core/Services/ReproScriptBuilder.cs +++ b/src/PlanViewer.Core/Services/ReproScriptBuilder.cs @@ -318,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 */ @@ -369,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(); diff --git a/src/PlanViewer.Core/Services/ShowPlanParser.cs b/src/PlanViewer.Core/Services/ShowPlanParser.cs index 0e3605b8..c068a4ea 100644 --- a/src/PlanViewer.Core/Services/ShowPlanParser.cs +++ b/src/PlanViewer.Core/Services/ShowPlanParser.cs @@ -1,10 +1,8 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Runtime.ExceptionServices; using System.Runtime.Versioning; -using System.Xml; using System.Xml.Linq; using PlanViewer.Core.Models; @@ -19,7 +17,7 @@ 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 @@ -34,15 +32,9 @@ public static ParsedPlan Parse(string xml) XDocument document; 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."); - document = XDocument.Parse(xml); + /* 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) { @@ -74,18 +66,7 @@ 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); + 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 diff --git a/src/PlanViewer.Web/PlanViewer.Web.csproj b/src/PlanViewer.Web/PlanViewer.Web.csproj index 50c52ed0..e243daaa 100644 --- a/src/PlanViewer.Web/PlanViewer.Web.csproj +++ b/src/PlanViewer.Web/PlanViewer.Web.csproj @@ -23,6 +23,7 @@ + diff --git a/tests/PlanViewer.Core.Tests/LongPredicateTests.cs b/tests/PlanViewer.Core.Tests/LongPredicateTests.cs new file mode 100644 index 00000000..6ef4b72f --- /dev/null +++ b/tests/PlanViewer.Core.Tests/LongPredicateTests.cs @@ -0,0 +1,80 @@ +using System.Linq; +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +/// +/// A predicate or statement in plan XML from an unknown source can be millions of characters +/// long. The analyzer read each of these inputs again from many starting points, so the time it +/// took grew with the square of the length, and each one took half a minute or more. Now each +/// takes well under a second. The time limit is loose on purpose, so a slow machine does not +/// fail these tests. +/// +public class LongPredicateTests +{ + private static readonly TimeSpan Limit = TimeSpan.FromSeconds(10); + + // An unaliased scan of table t, so [t].[c] is its column and [@p] is not. + private static readonly ScanIdentity Scan = new(null, "t", false, new HashSet()); + + private static string Repeat(string text, int count) => string.Concat(Enumerable.Repeat(text, count)); + + private static Task Within(Func analyze) => + Task.Run(analyze, TestContext.Current.CancellationToken) + .WaitAsync(Limit, TestContext.Current.CancellationToken); + + [Fact] + public async Task ARunOfOpeningBrackets() + { + var predicate = "[t].[c]=abs([@p])+" + new string('[', 150_000); + + Assert.Null(await Within(() => PlanAnalyzer.DetectNonSargablePattern(predicate, Scan))); + } + + [Fact] + public async Task ManyComparisonsWithAFunction() + { + // Only the last function wraps the column. + var predicate = Repeat("[t].[c]=abs([@p]) AND ", 10_000) + "abs([t].[c])=(1)"; + + Assert.Equal("Function call (ABS) on column", + await Within(() => PlanAnalyzer.DetectNonSargablePattern(predicate, Scan))); + } + + [Fact] + public async Task ManyFunctionsInOneComparison() + { + var predicate = "[t].[c]=" + Repeat("abs([@p])+", 50_000) + "(1)"; + + Assert.Null(await Within(() => PlanAnalyzer.DetectNonSargablePattern(predicate, Scan))); + } + + [Fact] + public async Task DeeplyNestedConversions() + { + var predicate = Repeat("CONVERT_IMPLICIT(int,", 60_000) + "[@p]" + Repeat(",0)", 60_000) + "=[t].[c]"; + + Assert.Null(await Within(() => PlanAnalyzer.DetectNonSargablePattern(predicate, Scan))); + } + + [Fact] + public async Task ManyLikesWithNoPattern() + { + var predicate = Repeat("like ", 150_000); + + Assert.Null(await Within(() => PlanAnalyzer.DetectNonSargablePattern(predicate, Scan))); + } + + [Fact] + public async Task ManyCursorDeclarationsWithNoFor() + { + var statement = new PlanStatement { StatementText = Repeat("DECLARE c CURSOR ", 20_000) }; + + await Within(() => + { + PlanAnalyzer.Analyze(new ParsedPlan { Batches = [new PlanBatch { Statements = [statement] }] }); + return statement.PlanWarnings.Count; + }); + } +} diff --git a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs index 8cd6aef5..2f32bd4d 100644 --- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs @@ -368,6 +368,35 @@ public void Rule12f_NonSargable_UnaliasedTableVariableParameterSideConversion_Is "[S]=CONVERT_IMPLICIT(nvarchar(20),[@n],0)", Identity(alias: null, table: "@tv", isTableVariable: true))); } + /// + /// A conversion nested inside another is not read on its own: its arguments are part of the + /// outer one's, which were read already. Conversions after the outer one still are. + /// + [Fact] + public void Rule12f_NonSargable_NestedConversionsAroundAParameter_AreNotFlagged() + { + Assert.False(PlanAnalyzer.ConvertImplicitWrapsColumn( + "[db].[dbo].[t].[c]=CONVERT_IMPLICIT(int,CONVERT_IMPLICIT(smallint,[@p],0),0)")); + } + + [Fact] + public void Rule12f_NonSargable_ConversionAfterNestedParameterConversions_IsStillRead() + { + Assert.True(PlanAnalyzer.ConvertImplicitWrapsColumn( + "CONVERT_IMPLICIT(int,CONVERT_IMPLICIT(smallint,[@p],0),0)=(1) AND CONVERT_IMPLICIT(int,[db].[dbo].[t].[c],0)=[@q]")); + } + + /// + /// Each side of a comparison is read once and remembered, so a call on the parameter side + /// must not decide the answer for a call on the column side of the same comparison. + /// + [Fact] + public void Rule12_NonSargable_FunctionsOnBothSidesOfOneComparison_TheColumnSideIsFlagged() + { + Assert.Equal("Function call (ABS) on column", PlanAnalyzer.DetectNonSargablePattern( + "abs([@p])=abs([db].[dbo].[t].[c])")); + } + // --------------------------------------------------------------- // Rule 12: Non-SARGable Predicate — bare columns on a table variable scan (#561) // --------------------------------------------------------------- diff --git a/tests/PlanViewer.Core.Tests/PlanXmlTests.cs b/tests/PlanViewer.Core.Tests/PlanXmlTests.cs new file mode 100644 index 00000000..bf1de2cc --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanXmlTests.cs @@ -0,0 +1,90 @@ +using System.Text; +using System.Xml; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +/// +/// Every site that loads plan XML goes through PlanXml. These pin its limits: no DTD, a limit on +/// nesting depth, and a limit on the sum of all nodes' depths, which is what XDocument's load +/// time grows with. +/// +public class PlanXmlTests +{ + private const string WithDtd = + "]>&e;"; + + [Fact] + public void ADtdIsRefused() + { + var error = Assert.Throws(() => PlanXml.Parse(WithDtd)); + + Assert.Contains("DTD", error.Message); + Assert.Contains("DTD", ShowPlanParser.Parse(WithDtd).ParseError); + } + + [Fact] + public async Task ADtdIsRefusedOnTheAsyncPath() + { + var plan = await ShowPlanParser.ParseAsync(WithDtd, TestContext.Current.CancellationToken); + + Assert.Contains("DTD", plan.ParseError); + } + + [Fact] + public void XmlAtTheDepthLimitLoads() + { + var document = PlanXml.Parse(Nested(PlanXml.MaxDepth)); + + Assert.NotNull(document.Root); + } + + [Fact] + public void XmlPastTheDepthLimitIsRefused() + { + var error = Assert.Throws(() => PlanXml.Parse(Nested(PlanXml.MaxDepth + 1))); + + Assert.Contains("depth limit", error.Message); + } + + [Fact] + public async Task XmlPastTheDepthLimitIsAParseErrorOnBothParserPaths() + { + var xml = Nested(PlanXml.MaxDepth + 1); + + Assert.Contains("depth limit", ShowPlanParser.Parse(xml).ParseError); + var plan = await ShowPlanParser.ParseAsync(xml, TestContext.Current.CancellationToken); + Assert.Contains("depth limit", plan.ParseError); + } + + [Fact] + public void TooManyDeeplyNestedElementsAreRefused() + { + /* Each element is within the depth limit, but together their depths pass the sum + limit: 1,000 levels, then empty elements one level further down. */ + const int chain = 1000; + var leaves = (int)(PlanXml.MaxDepthSum / (chain + 1)) + 1; + var xml = new StringBuilder(); + for (var level = 0; level <= chain; level++) + xml.Append(""); + for (var leaf = 0; leaf < leaves; leaf++) + xml.Append(""); + for (var level = 0; level <= chain; level++) + xml.Append(""); + + var error = Assert.Throws(() => PlanXml.Parse(xml.ToString())); + + Assert.Contains("deeply nested", error.Message); + } + + /// XML whose deepest element is levels below the root. + private static string Nested(int depth) + { + var xml = new StringBuilder(); + for (var level = 0; level <= depth; level++) + xml.Append(""); + for (var level = 0; level <= depth; level++) + xml.Append(""); + return xml.ToString(); + } +} From 87a02d93b97a4976a994dc9ba56d5ea33d5b9bfe Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:56:18 -0400 Subject: [PATCH 22/85] Harden the app's local surfaces: pipe, About links, SSMS temp plans, MCP host - Single-instance pipe: the app's server and client use PipeOptions.CurrentUserOnly (built in SingleInstance). The SSMS extension checks the pipe owner by hand (.NET Framework has no client option), accepting the user or token owner SID. - About window: OpenUrl opens only absolute http/https addresses. - SSMS handoff: the app deletes ssms_plan_*.sqlplan from the temp folder once read, and treats the tab like a pasted plan (no source path, not recent, not restored). - MCP host: CreateEmptyBuilder + UseKestrelCore + AddRoutingCore, so no appsettings or environment Kestrel section can add endpoints; requests from a non-loopback address get a 403. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- src/PlanViewer.App/AboutWindow.axaml.cs | 20 ++- src/PlanViewer.App/MainWindow.FileOps.cs | 39 ++++- src/PlanViewer.App/MainWindow.axaml.cs | 5 +- src/PlanViewer.App/Mcp/McpHostService.cs | 26 ++- src/PlanViewer.App/Program.cs | 8 +- src/PlanViewer.App/SingleInstance.cs | 20 +++ src/PlanViewer.Ssms/AppLauncher.cs | 25 +++ .../AboutWindowLinkTests.cs | 43 +++++ .../McpHostServiceTests.cs | 157 ++++++++++++++++++ .../SingleInstanceTests.cs | 58 +++++++ .../PlanViewer.Core.Tests/SsmsHandoffTests.cs | 107 ++++++++++++ 11 files changed, 494 insertions(+), 14 deletions(-) create mode 100644 tests/PlanViewer.Core.Tests/AboutWindowLinkTests.cs create mode 100644 tests/PlanViewer.Core.Tests/McpHostServiceTests.cs create mode 100644 tests/PlanViewer.Core.Tests/SsmsHandoffTests.cs 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/MainWindow.FileOps.cs b/src/PlanViewer.App/MainWindow.FileOps.cs index 24367394..f41e0cda 100644 --- a/src/PlanViewer.App/MainWindow.FileOps.cs +++ b/src/PlanViewer.App/MainWindow.FileOps.cs @@ -422,6 +422,20 @@ heals down to one tab per file. */ var xml = File.ReadAllText(filePath); + /* A plan sent from SSMS arrives as a temp file that the extension wrote for this one + handoff. It holds the query text and any parameter values, and once read it has + done its job, so it is deleted now. The tab is then treated like a pasted plan, + with no file behind it: it goes on neither the recent list nor the tabs restored + at the next start. When the delete fails, the extension's own sweep of files + older than an hour removes it later. */ + var ssmsHandoff = IsSsmsHandoffFile(fullPath); + if (ssmsHandoff) + { + try { File.Delete(fullPath); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + // SSMS saves plans as UTF-16 with encoding="utf-16" in the XML declaration. // File.ReadAllText auto-detects the BOM, but the resulting C# string still // contains encoding="utf-16" which causes XDocument.Parse to fail. @@ -435,7 +449,8 @@ heals down to one tab per file. */ var viewer = new PlanViewerControl(); viewer.SetConnectionServices(_credentialService, _connectionStore); viewer.LoadPlan(xml, fileName); - viewer.SourceFilePath = filePath; + if (!ssmsHandoff) + viewer.SourceFilePath = filePath; // Wrap viewer with advice toolbar var content = CreatePlanTabContent(viewer); @@ -446,7 +461,8 @@ heals down to one tab per file. */ UpdateEmptyOverlay(); // Track in recent plans list and persist - TrackRecentPlan(filePath); + if (!ssmsHandoff) + TrackRecentPlan(filePath); } catch (Exception ex) { @@ -454,6 +470,25 @@ heals down to one tab per file. */ } } + /// + /// True for a plan file that the SSMS extension wrote to hand over one plan: named + /// ssms_plan_*.sqlplan, directly in this user's temp folder, on Windows (the only place + /// the extension runs). All three must hold, so a file of the user's own that matches + /// the name somewhere else is never deleted. The extension sweeps that same pattern + /// from the temp folder itself. + /// + internal static bool IsSsmsHandoffFile(string fullPath) + { + var name = Path.GetFileName(fullPath); + return OperatingSystem.IsWindows() + && name.StartsWith("ssms_plan_", StringComparison.OrdinalIgnoreCase) + && name.EndsWith(".sqlplan", StringComparison.OrdinalIgnoreCase) + && string.Equals( + Path.GetDirectoryName(fullPath), + Path.TrimEndingDirectorySeparator(Path.GetFullPath(Path.GetTempPath())), + StringComparison.OrdinalIgnoreCase); + } + private async Task PasteXmlAsync() { var xml = await ClipboardHelper.TryGetTextAsync(this); diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index 8b5ba158..796cbd49 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; @@ -242,9 +241,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); diff --git a/src/PlanViewer.App/Mcp/McpHostService.cs b/src/PlanViewer.App/Mcp/McpHostService.cs index f457d138..6dff68ab 100644 --- a/src/PlanViewer.App/Mcp/McpHostService.cs +++ b/src/PlanViewer.App/Mcp/McpHostService.cs @@ -1,4 +1,5 @@ using System; +using System.Net; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; @@ -43,7 +44,13 @@ 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 +100,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; @@ -123,6 +138,15 @@ web page can point a DNS name it controls at 127.0.0.1 and reach this } } + 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/Program.cs b/src/PlanViewer.App/Program.cs index 82bb239d..6e9f9161 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; @@ -182,7 +181,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 +195,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/SingleInstance.cs b/src/PlanViewer.App/SingleInstance.cs index 625fe7a6..2bd53ad2 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; @@ -59,6 +60,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.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/tests/PlanViewer.Core.Tests/AboutWindowLinkTests.cs b/tests/PlanViewer.Core.Tests/AboutWindowLinkTests.cs new file mode 100644 index 00000000..4a97c292 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/AboutWindowLinkTests.cs @@ -0,0 +1,43 @@ +using PlanViewer.App; + +namespace PlanViewer.Core.Tests; + +/// +/// The About window opens links through the shell, and one of them, the update address, +/// comes from the release server's reply. Only absolute http and https addresses may be +/// opened; the shell runs a program when it is handed a path or another scheme. +/// +public class AboutWindowLinkTests +{ + [Theory] + [InlineData("https://github.com/erikdarlingdata/PerformanceStudio/releases/tag/v1.27.0")] + [InlineData("http://example.com/")] + [InlineData("HTTPS://GITHUB.COM/erikdarlingdata")] + public void WebAddressesAreOpened(string url) + { + Assert.NotNull(AboutWindow.WebPageAddress(url)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("calc.exe")] + [InlineData(@"C:\Windows\System32\calc.exe")] + [InlineData(@"\\server\share\setup.exe")] + [InlineData("file:///C:/Windows/System32/calc.exe")] + [InlineData("ms-settings:privacy")] + [InlineData("javascript:alert(1)")] + [InlineData("releases/latest")] + public void AnythingElseIsNot(string? url) + { + Assert.Null(AboutWindow.WebPageAddress(url)); + } + + [Fact] + public void TheAddressHandedToTheShellIsEscaped() + { + var page = AboutWindow.WebPageAddress("https://example.com/a b\"c"); + + Assert.Equal("https://example.com/a%20b%22c", page!.AbsoluteUri); + } +} diff --git a/tests/PlanViewer.Core.Tests/McpHostServiceTests.cs b/tests/PlanViewer.Core.Tests/McpHostServiceTests.cs new file mode 100644 index 00000000..b8532b0f --- /dev/null +++ b/tests/PlanViewer.Core.Tests/McpHostServiceTests.cs @@ -0,0 +1,157 @@ +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using PlanViewer.App.Mcp; +using PlanViewer.App.Services; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +/// +/// The MCP server as the app runs it: Kestrel on a loopback port, reached over HTTP. The host +/// is built by hand from an empty builder, so these pin that it still serves MCP, including a +/// tool call that needs its registered services, and that its guards refuse what they should. +/// +public class McpHostServiceTests +{ + [Fact] + public async Task AClientOnThisMachineCanListAndCallTools() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var server = await RunningServer.StartAsync(cancellationToken); + + await using var client = await McpClient.CreateAsync( + new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = server.Address, + TransportMode = HttpTransportMode.StreamableHttp + }), + cancellationToken: cancellationToken); + + var tools = await client.ListToolsAsync(cancellationToken: cancellationToken); + Assert.Contains(tools, tool => tool.Name == "list_plans"); + + var result = await client.CallToolAsync( + "list_plans", + new Dictionary(), + cancellationToken: cancellationToken); + Assert.NotEqual(true, result.IsError); + Assert.False(string.IsNullOrEmpty(Assert.IsType(result.Content[0]).Text)); + } + + [Fact] + public async Task ARequestNamingAnotherHostIsRefused() + { + var cancellationToken = TestContext.Current.CancellationToken; + await using var server = await RunningServer.StartAsync(cancellationToken); + + using var http = new HttpClient(); + using var request = new HttpRequestMessage(HttpMethod.Post, server.Address) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json") + }; + request.Headers.Host = "attacker.example"; + + using var response = await http.SendAsync(request, cancellationToken); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Theory] + [InlineData("127.0.0.1", true)] + [InlineData("127.0.0.2", true)] + [InlineData("::1", true)] + [InlineData("::ffff:127.0.0.1", true)] + [InlineData("10.0.0.5", false)] + [InlineData("::ffff:10.0.0.5", false)] + [InlineData("192.168.1.20", false)] + [InlineData("fe80::1", false)] + public void OnlyLoopbackAddressesMayConnect(string address, bool allowed) + { + Assert.Equal(allowed, McpHostService.IsLoopbackAddress(IPAddress.Parse(address))); + } + + [Fact] + public void AConnectionWithNoAddressIsRefused() + { + Assert.False(McpHostService.IsLoopbackAddress(null)); + } + + /// + /// One McpHostService on a free loopback port, stopped on dispose. It never saves the + /// connection store and keeps credentials in memory, so no test touches the user's files. + /// + private sealed class RunningServer : IAsyncDisposable + { + private readonly McpHostService _service; + + private RunningServer(McpHostService service, int port) + { + _service = service; + Address = new Uri($"http://localhost:{port}/"); + } + + public Uri Address { get; } + + public static async Task StartAsync(CancellationToken cancellationToken) + { + var port = FreePort(); + var service = new McpHostService( + new PlanSessionManager(), new ConnectionStore(), new InMemoryCredentialService(), port); + await service.StartAsync(cancellationToken); + + var server = new RunningServer(service, port); + try + { + await WaitUntilListeningAsync(port, cancellationToken); + } + catch + { + await server.DisposeAsync(); + throw; + } + + return server; + } + + public async ValueTask DisposeAsync() + { + await _service.StopAsync(CancellationToken.None); + _service.Dispose(); + } + + private static int FreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + /// + /// The service starts its host in the background and reports a failure to start only + /// to the debugger, so the port is the one sign that it came up. + /// + private static async Task WaitUntilListeningAsync(int port, CancellationToken cancellationToken) + { + var deadline = DateTime.UtcNow.AddSeconds(20); + while (true) + { + try + { + using var probe = new TcpClient(); + await probe.ConnectAsync(IPAddress.Loopback, port, cancellationToken); + return; + } + catch (SocketException) when (DateTime.UtcNow < deadline) + { + await Task.Delay(50, cancellationToken); + } + } + } + } +} diff --git a/tests/PlanViewer.Core.Tests/SingleInstanceTests.cs b/tests/PlanViewer.Core.Tests/SingleInstanceTests.cs index 89ddfee1..bdb1e50e 100644 --- a/tests/PlanViewer.Core.Tests/SingleInstanceTests.cs +++ b/tests/PlanViewer.Core.Tests/SingleInstanceTests.cs @@ -1,6 +1,10 @@ using System.Threading; using System.IO; +using System.IO.Pipes; using System.Linq; +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; using Avalonia.Controls; using PlanViewer.App; using PlanViewer.App.Controls; @@ -250,6 +254,60 @@ public void NamedMutexMachineryWorksOnThisPlatform() Assert.False(createdSecond, "a second open of the same name must see the existing mutex"); } + /* ---- The pipe: both ends open it for the current user only --------------------- */ + + [Fact] + public async Task ALineSentByTheClientReachesTheServer() + { + var cancellationToken = TestContext.Current.CancellationToken; + // A unique name, for the same reason as the mutex self-test above. + var name = $"{SingleInstance.PipeName}_selftest_{Guid.NewGuid():N}"; + + using var server = SingleInstance.CreatePipeServer(name); + // Reading starts before the client writes: a write to the pipe can wait for its reader. + var received = ReceiveOneLineAsync(server, cancellationToken); + + using (var client = SingleInstance.CreatePipeClient(name)) + { + await client.ConnectAsync(5000, cancellationToken); + using var writer = new StreamWriter(client); + await writer.WriteLineAsync(SingleInstance.ActivateSentinel); + await writer.FlushAsync(cancellationToken); + } + + Assert.Equal( + SingleInstance.ActivateSentinel, + await received.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken)); + } + + private static async Task ReceiveOneLineAsync( + NamedPipeServerStream server, CancellationToken cancellationToken) + { + await server.WaitForConnectionAsync(cancellationToken); + using var reader = new StreamReader(server, leaveOpen: true); + return await reader.ReadLineAsync(cancellationToken); + } + + [Fact] + [SupportedOSPlatform("windows")] + public void OnWindowsThePipeGrantsAccessToItsOwnerAlone() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "access lists on pipes are a Windows feature"); + + using var server = SingleInstance.CreatePipeServer($"{SingleInstance.PipeName}_selftest_{Guid.NewGuid():N}"); + using var identity = WindowsIdentity.GetCurrent(); + + var security = server.GetAccessControl(); + var rules = security.GetAccessRules(true, true, typeof(SecurityIdentifier)) + .Cast() + .ToList(); + + Assert.Equal(identity.Owner, security.GetOwner(typeof(SecurityIdentifier))); + var rule = Assert.Single(rules); + Assert.Equal(AccessControlType.Allow, rule.AccessControlType); + Assert.Equal(identity.Owner, rule.IdentityReference); + } + private static string TempSql(string text) { var path = Path.Combine(Path.GetTempPath(), $"{Path.GetRandomFileName()}.sql"); diff --git a/tests/PlanViewer.Core.Tests/SsmsHandoffTests.cs b/tests/PlanViewer.Core.Tests/SsmsHandoffTests.cs new file mode 100644 index 00000000..99f3ea85 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/SsmsHandoffTests.cs @@ -0,0 +1,107 @@ +using System.IO; +using System.Linq; +using Avalonia.Controls; +using PlanViewer.App; +using PlanViewer.App.Controls; +using PlanViewer.App.Services; + +namespace PlanViewer.Core.Tests; + +/// +/// The SSMS extension hands a plan to the app as a temp file, ssms_plan_*.sqlplan, holding +/// the query text and any parameter values. The app deletes that file once it has read it and +/// treats the tab like a pasted plan: no file behind it, not on the recent list, not restored +/// at the next start. A file of the user's own is never deleted. +/// +public class SsmsHandoffTests +{ + [Fact] + public void APlanFromSsmsIsDeletedOnceReadAndNotRemembered() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "the SSMS extension runs only on Windows"); + + HeadlessUi.Run(() => + { + var path = Path.Combine(Path.GetTempPath(), $"ssms_plan_{Path.GetRandomFileName()}.sqlplan"); + File.Copy(PlanPath("key_lookup_plan.sqlplan"), path); + try + { + var window = new MainWindow(); + window.LoadPlanFile(path); + + Assert.False(File.Exists(path), "the handoff file is deleted once read"); + var viewer = OpenedViewer(window); + Assert.Null(viewer.SourceFilePath); + Assert.DoesNotContain(path, window.RecentPlans); + Assert.DoesNotContain(path, window.CollectOpenTabEntries()); + } + finally + { + File.Delete(path); + } + }); + } + + [Fact] + public void AFileWithTheSameNameOutsideTheTempFolderIsKept() + { + HeadlessUi.Run(() => + { + var folder = Directory.CreateTempSubdirectory("ssms-handoff-test-"); + var path = Path.Combine(folder.FullName, "ssms_plan_mine.sqlplan"); + File.Copy(PlanPath("key_lookup_plan.sqlplan"), path); + try + { + var window = new MainWindow(); + window.LoadPlanFile(path); + + Assert.True(File.Exists(path)); + var viewer = OpenedViewer(window); + Assert.Equal(path, viewer.SourceFilePath); + Assert.Contains(path, window.RecentPlans); + Assert.Contains(path, window.CollectOpenTabEntries()); + } + finally + { + var settings = AppSettingsService.Load(); + AppSettingsService.RemoveRecentPlan(settings, path); + AppSettingsService.Save(settings); + folder.Delete(recursive: true); + } + }); + } + + [Fact] + public void OnlyTheExtensionsFileNameDirectlyInTheTempFolderCounts() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), "the SSMS extension runs only on Windows"); + var temp = Path.GetTempPath(); + + Assert.True(MainWindow.IsSsmsHandoffFile(Path.Combine(temp, "ssms_plan_ab12cd34.sqlplan"))); + Assert.True(MainWindow.IsSsmsHandoffFile(Path.Combine(temp, "SSMS_PLAN_AB12CD34.SQLPLAN"))); + Assert.False(MainWindow.IsSsmsHandoffFile(Path.Combine(temp, "sub", "ssms_plan_ab12cd34.sqlplan"))); + Assert.False(MainWindow.IsSsmsHandoffFile(Path.Combine(temp, "my_plan.sqlplan"))); + Assert.False(MainWindow.IsSsmsHandoffFile(Path.Combine(temp, "ssms_plan_ab12cd34.sql"))); + Assert.False(MainWindow.IsSsmsHandoffFile(@"C:\Plans\ssms_plan_ab12cd34.sqlplan")); + } + + [Fact] + public void OffWindowsNoFileCounts() + { + Assert.SkipWhen(OperatingSystem.IsWindows(), "pins the contract where the extension never runs"); + + Assert.False(MainWindow.IsSsmsHandoffFile( + Path.Combine(Path.GetTempPath(), "ssms_plan_ab12cd34.sqlplan"))); + } + + private static string PlanPath(string name) => + Path.Combine(AppContext.BaseDirectory, "Plans", name); + + /// The viewer in the tab that LoadPlanFile just opened and selected. + private static PlanViewerControl OpenedViewer(MainWindow window) + { + var tab = Assert.IsType(window.MainTabControl.SelectedItem); + var dock = Assert.IsType(tab.Content); + return Assert.Single(dock.Children.OfType()); + } +} From 426a031c75e7a7834ede7a0507048b292a2deaa5 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:02:29 -0400 Subject: [PATCH 23/85] PlanXml: limit namespace URI length and attributes per element - Refuse a namespace declaration longer than 256 characters. XDocument hashes the whole URI each time the namespace changes between names, so a long URI used by alternating names cost its length per change. - Refuse an element with more than 1,024 attributes after the first read, so XDocument doesn't read such a tag a second time. - Throw ArgumentNullException for null, as XDocument.Parse did. - Reword the nested-conversion comment and pin that conversion text inside a string literal is not read on its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Services/PlanAnalyzer.Detection.cs | 8 +-- src/PlanViewer.Core/Services/PlanXml.cs | 41 +++++++++++++++- .../PlanAnalyzerTests.cs | 13 +++++ tests/PlanViewer.Core.Tests/PlanXmlTests.cs | 49 +++++++++++++++++++ 4 files changed, 107 insertions(+), 4 deletions(-) diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs index f59c220f..b338b81b 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Detection.cs @@ -350,9 +350,11 @@ internal static bool ConvertImplicitWrapsColumn(string predicate, ScanIdentity? foreach (Match match in ConvertImplicitRegex.Matches(predicate)) { - // A conversion nested inside one already read: its arguments are part of that one's, - // which held no column. Reading each nested list again took time in proportion to the - // square of the predicate's length when conversions were nested thousands deep. + // 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; diff --git a/src/PlanViewer.Core/Services/PlanXml.cs b/src/PlanViewer.Core/Services/PlanXml.cs index c2e1ff5c..fe0fa5e2 100644 --- a/src/PlanViewer.Core/Services/PlanXml.cs +++ b/src/PlanViewer.Core/Services/PlanXml.cs @@ -33,6 +33,23 @@ internal static class PlanXml ///
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. Reading a start tag with a huge number of them + /// is slow even for XmlReader, so such a tag is refused after the first read instead of + /// being read a second time by XDocument. Real plans have at most 20 on one element. + /// + internal const int MaxAttributes = 1024; + + private const string XmlnsNamespace = "http://www.w3.org/2000/xmlns/"; + internal static XDocument Parse(string xml) { CheckLimits(xml); @@ -49,10 +66,13 @@ internal static async Task ParseAsync(string xml, CancellationToken c /// /// 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 or too deeply nested. + /// 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."); @@ -71,9 +91,28 @@ private static void CheckLimits(string xml) 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); } } + private static void CheckAttributes(XmlReader reader) + { + if (reader.AttributeCount > MaxAttributes) + throw new XmlException( + $"Plan XML has an element with more than {MaxAttributes.ToString("N0", CultureInfo.InvariantCulture)} attributes."); + + 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. diff --git a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs index 2f32bd4d..8a45c5f0 100644 --- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs @@ -386,6 +386,19 @@ public void Rule12f_NonSargable_ConversionAfterNestedParameterConversions_IsStil "CONVERT_IMPLICIT(int,CONVERT_IMPLICIT(smallint,[@p],0),0)=(1) AND CONVERT_IMPLICIT(int,[db].[dbo].[t].[c],0)=[@q]")); } + /// + /// Conversion text inside a string literal is not a conversion. It sits inside the arguments + /// of the real conversion around it, so it is not read on its own. + /// + [Fact] + public void Rule12f_NonSargable_ConversionTextInsideAStringLiteral_IsNotRead() + { + var scan = new ScanIdentity(null, "T", false, new HashSet()); + + Assert.False(PlanAnalyzer.ConvertImplicitWrapsColumn( + "CONVERT_IMPLICIT(nvarchar(50),N'x CONVERT_IMPLICIT(int,[T].[c],0)',0)=[@p]", scan)); + } + /// /// Each side of a comparison is read once and remembered, so a call on the parameter side /// must not decide the answer for a call on the column side of the same comparison. diff --git a/tests/PlanViewer.Core.Tests/PlanXmlTests.cs b/tests/PlanViewer.Core.Tests/PlanXmlTests.cs index bf1de2cc..0f10be48 100644 --- a/tests/PlanViewer.Core.Tests/PlanXmlTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanXmlTests.cs @@ -77,6 +77,55 @@ public void TooManyDeeplyNestedElementsAreRefused() Assert.Contains("deeply nested", error.Message); } + [Theory] + [InlineData("xmlns:a")] + [InlineData("xmlns")] + public void ANamespacePastTheLengthLimitIsRefused(string declaration) + { + var uri = "urn:" + new string('u', PlanXml.MaxNamespaceLength); + + var error = Assert.Throws(() => PlanXml.Parse($"")); + + Assert.Contains("namespace", error.Message); + } + + [Fact] + public void ANamespaceAtTheLengthLimitLoads() + { + var uri = "urn:" + new string('u', PlanXml.MaxNamespaceLength - 4); + + Assert.NotNull(PlanXml.Parse($"").Root); + } + + [Fact] + public void AnElementAtTheAttributeLimitLoads() + { + Assert.NotNull(PlanXml.Parse(WithAttributes(PlanXml.MaxAttributes)).Root); + } + + [Fact] + public void AnElementPastTheAttributeLimitIsRefused() + { + var error = Assert.Throws(() => PlanXml.Parse(WithAttributes(PlanXml.MaxAttributes + 1))); + + Assert.Contains("attributes", error.Message); + } + + [Fact] + public void NullIsAnArgumentError() + { + Assert.Throws(() => PlanXml.Parse(null!)); + } + + /// One element with attributes. + private static string WithAttributes(int count) + { + var xml = new StringBuilder("").ToString(); + } + /// XML whose deepest element is levels below the root. private static string Nested(int depth) { From 42155f91462dbf6f4e212dd11abf73c531c27646 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:31:57 -0400 Subject: [PATCH 24/85] Delete the SSMS handoff file only after the plan loads A file that fails to load, or to delete, is left for the extension's sweep of files older than an hour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- src/PlanViewer.App/MainWindow.FileOps.cs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/PlanViewer.App/MainWindow.FileOps.cs b/src/PlanViewer.App/MainWindow.FileOps.cs index f41e0cda..1c5d459e 100644 --- a/src/PlanViewer.App/MainWindow.FileOps.cs +++ b/src/PlanViewer.App/MainWindow.FileOps.cs @@ -423,18 +423,12 @@ heals down to one tab per file. */ var xml = File.ReadAllText(filePath); /* A plan sent from SSMS arrives as a temp file that the extension wrote for this one - handoff. It holds the query text and any parameter values, and once read it has - done its job, so it is deleted now. The tab is then treated like a pasted plan, - with no file behind it: it goes on neither the recent list nor the tabs restored - at the next start. When the delete fails, the extension's own sweep of files - older than an hour removes it later. */ + handoff. It holds the query text and any parameter values, so once the plan has + loaded, the file is deleted. The tab is then treated like a pasted plan, with no + file behind it: it goes on neither the recent list nor the tabs restored at the + next start. A file that fails to load, or to delete, is left for the extension's + own sweep of files older than an hour. */ var ssmsHandoff = IsSsmsHandoffFile(fullPath); - if (ssmsHandoff) - { - try { File.Delete(fullPath); } - catch (IOException) { } - catch (UnauthorizedAccessException) { } - } // SSMS saves plans as UTF-16 with encoding="utf-16" in the XML declaration. // File.ReadAllText auto-detects the BOM, but the resulting C# string still @@ -448,9 +442,15 @@ older than an hour removes it later. */ var viewer = new PlanViewerControl(); viewer.SetConnectionServices(_credentialService, _connectionStore); - viewer.LoadPlan(xml, fileName); + var loaded = viewer.LoadPlan(xml, fileName); if (!ssmsHandoff) viewer.SourceFilePath = filePath; + else if (loaded) + { + try { File.Delete(fullPath); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } // Wrap viewer with advice toolbar var content = CreatePlanTabContent(viewer); From 3c2590acc6057ce421b62006b44523d9f4451665 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:45:08 -0400 Subject: [PATCH 25/85] Fix #594: node label, edge color, and minimap divide by per-execution estimate EstimateRows is SQL Server's estimate for one execution. ActualRows is the total across every execution, or across every thread in a parallel plan. The node label, edge color, and minimap divided the total by the raw per-execution estimate with no adjustment for either case, so a node on the inner side of a Nested Loops join (which really does run once per outer row) looked like a huge misestimate. Adds RowEstimateHelper in PlanViewer.Core: it multiplies the estimate by ActualExecutions only when the node is on the inner side of a Nested Loops join (walking the Parent chain to the join's second child) and ActualExecutions > 0. Everywhere else, ActualExecutions counts threads in a parallel plan, not repeats, so the estimate stays at one execution. The desktop app's node label and GetLinkColorBrush, the Blazor web viewer's node label, and analyzer rules 5, 16, and 26 all had their own copy of this math and now share the one helper. The CLI had no copy to fix. Golden-master baseline regenerated; every changed warning traces to this fix (see PR body for the fixture-by-fixture diff). Fix #595: Runtime Summary showed "0 KB granted, 0 KB used (100%)" for a statement with no memory grant. Now shows "No memory grant" in the neutral color, with no percentage. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Controls/PlanViewerControl.Rendering.cs | 19 +- .../PlanViewerControl.RuntimeSummary.cs | 21 +- .../Services/PlanAnalyzer.Node.cs | 38 +-- .../Services/RowEstimateHelper.cs | 71 ++++++ src/PlanViewer.Web/Pages/Index.razor | 11 +- src/PlanViewer.Web/PlanViewer.Web.csproj | 1 + .../AnalyzerRuleGateTests.cs | 122 ++++++++++ .../NodeLabelRowAccuracyTests.cs | 67 +++++ .../PlanAnalyzerTests.cs | 16 ++ .../RowEstimateHelperTests.cs | 230 ++++++++++++++++++ .../RuntimeSummaryMemoryGrantTests.cs | 78 ++++++ .../PlanViewer.Core.Tests/WarningBaseline.txt | 14 +- 12 files changed, 648 insertions(+), 40 deletions(-) create mode 100644 src/PlanViewer.Core/Services/RowEstimateHelper.cs create mode 100644 tests/PlanViewer.Core.Tests/NodeLabelRowAccuracyTests.cs create mode 100644 tests/PlanViewer.Core.Tests/RowEstimateHelperTests.cs create mode 100644 tests/PlanViewer.Core.Tests/RuntimeSummaryMemoryGrantTests.cs diff --git a/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs index a5f0f441..4f7cb4be 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs @@ -295,15 +295,20 @@ 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. + var expectedRows = RowEstimateHelper.GetExpectedRows(node); + var accuracyRatio = RowEstimateHelper.GetRowAccuracyRatio(node); IBrush rowBrush = (accuracyRatio < 1.0 / divergenceLimit || accuracyRatio > divergenceLimit) ? OrangeRedBrush : fgBrush; - var accuracy = estRows > 0 + var accuracy = expectedRows > 0 ? $" ({accuracyRatio * 100:F0}%)" : ""; stack.Children.Add(new TextBlock { - Text = $"{node.ActualRows:N0} of {estRows:N0}{accuracy}", + Text = $"{node.ActualRows:N0} of {expectedRows:N0}{accuracy}", FontSize = 10, Foreground = rowBrush, TextAlignment = TextAlignment.Center, @@ -382,10 +387,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..cfa38123 100644 --- a/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs +++ b/src/PlanViewer.App/Controls/PlanViewerControl.RuntimeSummary.cs @@ -147,13 +147,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"); } diff --git a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs index b7abeb61..c6d7e5e5 100644 --- a/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs +++ b/src/PlanViewer.Core/Services/PlanAnalyzer.Node.cs @@ -180,10 +180,13 @@ operators the same way. */ } else { - // Compare per-execution actuals to estimates (SQL Server estimates are per-execution) - var executions = node.ActualExecutions; - 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); @@ -191,8 +194,8 @@ operators the same way. */ { 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 { @@ -711,15 +714,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."); } } @@ -895,9 +903,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) 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.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index f4878cdd..e9b6b09d 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -508,13 +508,16 @@ else builder.AddContent(27, $"CPU: {ownCpuSec:F3}s"); builder.CloseElement(); - var estRows = node.EstimateRows; - var ratio = estRows > 0 ? node.ActualRows / estRows : (node.ActualRows > 0 ? double.MaxValue : 1.0); + // #594: EstimateRows is per execution — RowEstimateHelper scales it by + // ActualExecutions only on the inner side of a Nested Loops join, where that count is + // real rather than a parallel zone's thread count. + var expectedRows = RowEstimateHelper.GetExpectedRows(node); + var ratio = RowEstimateHelper.GetRowAccuracyRatio(node); var rowClass = (ratio < 0.1 || ratio > 10.0) ? " rows-skewed" : ""; - var accuracy = estRows > 0 ? $" ({ratio * 100:F0}%)" : ""; + var accuracy = expectedRows > 0 ? $" ({ratio * 100:F0}%)" : ""; builder.OpenElement(28, "div"); builder.AddAttribute(29, "class", $"node-rows{rowClass}"); - builder.AddContent(30, $"{node.ActualRows:N0} of {estRows:N0}{accuracy}"); + builder.AddContent(30, $"{node.ActualRows:N0} of {expectedRows:N0}{accuracy}"); builder.CloseElement(); } else diff --git a/src/PlanViewer.Web/PlanViewer.Web.csproj b/src/PlanViewer.Web/PlanViewer.Web.csproj index 50c52ed0..eb78a3bf 100644 --- a/src/PlanViewer.Web/PlanViewer.Web.csproj +++ b/src/PlanViewer.Web/PlanViewer.Web.csproj @@ -30,6 +30,7 @@ + diff --git a/tests/PlanViewer.Core.Tests/AnalyzerRuleGateTests.cs b/tests/PlanViewer.Core.Tests/AnalyzerRuleGateTests.cs index 96f2471a..8dc13a1e 100644 --- a/tests/PlanViewer.Core.Tests/AnalyzerRuleGateTests.cs +++ b/tests/PlanViewer.Core.Tests/AnalyzerRuleGateTests.cs @@ -290,4 +290,126 @@ public void Rule30_TwoSuggestionsForOneTable_AreStillDuplicates() Assert.Single(stmt.PlanWarnings, w => w.WarningType == "Duplicate Index Suggestions"); } + + // ---- #594: rule 5 compares ActualRows to an execution-aware estimate — see RowEstimateHelper + + [Fact] + public void Rule05_NonInnerSideNodeInParallelZone_IsNotOverstatedByThreadCount() + { + // Same shape as eager_index_spool_plan.sqlplan's Node 1: a Nested Loops join at DOP 8 + // under a Gather Streams parent. ActualExecutions=8 there is a thread count (each thread + // ran the join once), not eight real re-executions. A 4.9x overestimate should not clear + // the 10x gate — the pre-fix bug divided by the thread count and read this as 39x. + var gatherStreams = new PlanNode { PhysicalOp = "Parallelism", LogicalOp = "Gather Streams" }; + var node = new PlanNode + { + PhysicalOp = "Nested Loops", + LogicalOp = "Inner Join", + HasActualStats = true, + EstimateRows = 2983.02, + ActualRows = 609, + ActualExecutions = 8, + Parent = gatherStreams + }; + gatherStreams.Children.Add(node); + Analyze(new PlanStatement { RootNode = gatherStreams }); + + Assert.False(Has(node, "Row Estimate Mismatch")); + } + + [Fact] + public void Rule05_InnerSideNodeWithARealMismatch_StillFiresWithPerExecutionPhrasing() + { + // A node on the inner side of a Nested Loops join really does run once per outer row, so + // a genuine per-execution mismatch must still fire — the fix only changes what counts as + // "per execution", not whether inner-side nodes are checked at all. + var outer = new PlanNode { PhysicalOp = "Clustered Index Scan" }; + var inner = new PlanNode + { + PhysicalOp = "Index Seek", + LogicalOp = "Index Seek", + HasActualStats = true, + EstimateRows = 1, + ActualRows = 50_000, + ActualExecutions = 1000 + }; + var nl = new PlanNode + { + PhysicalOp = "Nested Loops", + LogicalOp = "Inner Join", + Children = { outer, inner } + }; + outer.Parent = nl; + inner.Parent = nl; + Analyze(new PlanStatement { RootNode = nl }); + + var warning = Assert.Single(inner.Warnings, w => w.WarningType == "Row Estimate Mismatch"); + Assert.Contains("50x underestimated", warning.Message); + Assert.Contains("50 rows x 1,000 executions", warning.Message); + } + + // ---- #594: rule 16's outer-side mismatch also has to compare like with like --------------- + + [Fact] + public void Rule16_OuterChildNotInnerSideInParallelZone_UsesTotalActualRowsNotPerThread() + { + // outerChild is this join's own OUTER input — not inner-side of anything — but sits in a + // parallel zone where ActualExecutions (8) is a thread count, not real repeats. The + // pre-fix bug divided the real actual rows by that thread count unconditionally. + var outerChild = new PlanNode + { + PhysicalOp = "Clustered Index Scan", + HasActualStats = true, + EstimateRows = 100, + ActualRows = 100_000, + ActualExecutions = 8 + }; + var innerChild = new PlanNode + { + PhysicalOp = "Key Lookup", + HasActualStats = true, + ActualExecutions = 200_000 + }; + var nl = new PlanNode + { + PhysicalOp = "Nested Loops", + LogicalOp = "Inner Join", + Children = { outerChild, innerChild } + }; + outerChild.Parent = nl; + innerChild.Parent = nl; + + Analyze(new PlanStatement { RootNode = nl }); + + var warning = Assert.Single(nl.Warnings, w => w.WarningType == "Nested Loops High Executions"); + // Pre-fix this read "actual 12,500 (125x underestimate)" — the real 100,000 rows divided + // by the 8-thread count instead of compared directly against the estimate. + Assert.Contains("Outer side: estimated 100 rows, actual 100,000 (1000x underestimate)", warning.Message); + Assert.DoesNotContain("12,500", warning.Message); + } + + // ---- #594: rule 26's row-goal check also has to compare like with like -------------------- + + [Fact] + public void Rule26_NonInnerSideScanInParallelZone_RowGoalCheckUsesTotalActualRows() + { + // A scan that isn't inner-side but sits in a parallel zone where ActualExecutions (8) is + // a thread count. Pre-fix, dividing the real actual by that thread count could make a row + // goal that did NOT hold (the undivided actual exceeds the reduced estimate) look like it + // held, silently swallowing the warning. + var scan = new PlanNode + { + PhysicalOp = "Index Scan", + LogicalOp = "Index Scan", + HasActualStats = true, + EstimateRows = 100, + EstimateRowsWithoutRowGoal = 1000, + ActualRows = 500, + ActualExecutions = 8 + }; + Analyze(new PlanStatement { RootNode = scan }); + + var warning = Assert.Single(scan.Warnings, w => w.WarningType == "Row Goal"); + Assert.Contains("estimate reduced from 1,000 to 100", warning.Message); + } } diff --git a/tests/PlanViewer.Core.Tests/NodeLabelRowAccuracyTests.cs b/tests/PlanViewer.Core.Tests/NodeLabelRowAccuracyTests.cs new file mode 100644 index 00000000..80822248 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/NodeLabelRowAccuracyTests.cs @@ -0,0 +1,67 @@ +using System.IO; +using System.Linq; +using Avalonia.Controls; +using Avalonia.LogicalTree; +using PlanViewer.App.Controls; + +namespace PlanViewer.Core.Tests; + +/// +/// #594: the node label compared an operator's total actual rows to its PER-EXECUTION estimate +/// with no adjustment for how many times the operator ran — correct for a node that only ran +/// once, wildly wrong for one on the inner side of a Nested Loops join. eager_index_spool_plan +/// is the fixture the coordinator worked the numbers from (DOP 8, every RelOp Parallel="true"). +/// +/// Node 0 (Gather Streams) and Node 1 (the Nested Loops join itself) are not inner-side — +/// both read "609 of 2,983 (20%)", unchanged by the fix. Nodes 4 and 5 (Top and Index Spool) are +/// both inner-side of Node 1 and share identical per-thread counters in this fixture, so both +/// should read "609 of 613 (99%)" — not the "609 of 1 (60,900%)" the old per-execution-blind math +/// showed. +/// +public class NodeLabelRowAccuracyTests +{ + [Fact] + public void EagerIndexSpoolPlan_NodeLabels_CompareActualRowsToExecutionAwareEstimate() + { + HeadlessUi.Run(() => + { + var viewer = LoadPlan("eager_index_spool_plan.sqlplan"); + var window = new Window { Content = viewer, Width = 1600, Height = 1000 }; + window.Show(); + window.UpdateLayout(); + + var labels = viewer.GetLogicalDescendants().OfType() + .Where(t => t.Text != null && t.Text.StartsWith("609 of ")) + .ToList(); + + // Node 0 (Gather Streams) and Node 1 (the join) — not inner-side, unaffected by #594. + var outerLabels = labels.Where(t => t.Text!.StartsWith("609 of 2,983")).ToList(); + Assert.Equal(2, outerLabels.Count); + Assert.All(outerLabels, t => Assert.Contains("(20%)", t.Text)); + + // Node 4 (Top) and Node 5 (Index Spool) — both inner-side of Node 1. + var innerLabels = labels.Where(t => t.Text!.StartsWith("609 of 613")).ToList(); + Assert.Equal(2, innerLabels.Count); + Assert.All(innerLabels, t => Assert.Contains("(99%)", t.Text)); + + // Neutral color, matching the outer nodes (well within the divergence band) — not the + // OrangeRed the pre-fix "609 of 1 (60,900%)" reading would have painted it. + Assert.All(innerLabels, t => Assert.Equal(outerLabels[0].Foreground, t.Foreground)); + + // The bug's own worked example: nothing in this plan reads the old, per-execution- + // blind "609 of 1" — every 609-actual node compares against an execution-aware total. + Assert.DoesNotContain(labels, t => t.Text!.StartsWith("609 of 1 ") || t.Text == "609 of 1"); + }); + } + + private static PlanViewerControl LoadPlan(string planFileName) + { + var path = Path.Combine("Plans", planFileName); + Assert.True(File.Exists(path), $"Test plan not found: {path}"); + var xml = File.ReadAllText(path).Replace("encoding=\"utf-16\"", "encoding=\"utf-8\""); + + var viewer = new PlanViewerControl(); + Assert.True(viewer.LoadPlan(xml, planFileName), $"Plan failed to load: {viewer.LastLoadError}"); + return viewer; + } +} diff --git a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs index 8cd6aef5..888710f4 100644 --- a/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs +++ b/tests/PlanViewer.Core.Tests/PlanAnalyzerTests.cs @@ -89,6 +89,22 @@ public void Rule05_RowEstimateMismatch_FalsePositivesSuppressed() Assert.Empty(warnings); } + /// + /// #594: Node 1 is a DOP-8 Nested Loops join whose ActualExecutions (8) is a thread count, + /// not a real re-execution count — it is not on the inner side of any join. Its true + /// mismatch is 609 actual against an estimate of 2,983.02, a 4.9x overestimate, well inside + /// the 10x gate. The pre-fix bug divided by the thread count and reported this node as a 39x + /// overestimate instead. + /// + [Fact] + public void Rule05_EagerIndexSpoolPlan_Node1IsNotOverstatedByItsThreadCount() + { + var plan = PlanTestHelper.LoadAndAnalyze("eager_index_spool_plan.sqlplan"); + var node1 = PlanTestHelper.FindNode(plan.Batches[0].Statements[0].RootNode!, 1)!; + + Assert.DoesNotContain(node1.Warnings, w => w.WarningType == "Row Estimate Mismatch"); + } + // --------------------------------------------------------------- // Rule 6: Scalar UDF Reference // --------------------------------------------------------------- diff --git a/tests/PlanViewer.Core.Tests/RowEstimateHelperTests.cs b/tests/PlanViewer.Core.Tests/RowEstimateHelperTests.cs new file mode 100644 index 00000000..ce8ed2c5 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/RowEstimateHelperTests.cs @@ -0,0 +1,230 @@ +using PlanViewer.Core.Models; +using PlanViewer.Core.Services; + +namespace PlanViewer.Core.Tests; + +/// +/// #594: EstimateRows is always per execution. ActualRows is the total across every execution — +/// or, in a parallel zone, across every thread. RowEstimateHelper decides when ActualExecutions +/// is a real per-execution count (the inner side of a Nested Loops join) versus a parallel +/// zone's thread count (everywhere else). eager_index_spool_plan.sqlplan is the fixture the +/// coordinator worked the numbers from: DOP 8, every RelOp Parallel="true". +/// +public class RowEstimateHelperTests +{ + // --------------------------------------------------------------- + // IsInnerSideOfNestedLoops — hand-built trees, full control over Parent/Children wiring + // --------------------------------------------------------------- + + [Fact] + public void IsInnerSide_NoNestedLoopsAncestor_IsFalse() + { + var node = new PlanNode { PhysicalOp = "Clustered Index Scan" }; + + Assert.False(RowEstimateHelper.IsInnerSideOfNestedLoops(node)); + } + + /// + /// The OUTER (first) child of a Nested Loops join is not inner-side — it runs once, not once + /// per outer row. + /// + [Fact] + public void IsInnerSide_OuterChildOfNestedLoops_IsFalse() + { + var outer = new PlanNode { PhysicalOp = "Sort" }; + var inner = new PlanNode { PhysicalOp = "Index Seek" }; + var nl = new PlanNode { PhysicalOp = "Nested Loops", Children = { outer, inner } }; + outer.Parent = nl; + inner.Parent = nl; + + Assert.False(RowEstimateHelper.IsInnerSideOfNestedLoops(outer)); + } + + /// + /// The INNER (second) child of a Nested Loops join is inner-side — the direct case #594 is + /// about. + /// + [Fact] + public void IsInnerSide_InnerChildOfNestedLoops_IsTrue() + { + var outer = new PlanNode { PhysicalOp = "Sort" }; + var inner = new PlanNode { PhysicalOp = "Index Seek" }; + var nl = new PlanNode { PhysicalOp = "Nested Loops", Children = { outer, inner } }; + outer.Parent = nl; + inner.Parent = nl; + + Assert.True(RowEstimateHelper.IsInnerSideOfNestedLoops(inner)); + } + + /// + /// A node several levels below the inner child (e.g. a spool feeding a scan) is still + /// inner-side — the walk climbs through every ancestor, not just the immediate parent. + /// + [Fact] + public void IsInnerSide_DescendantOfInnerChild_IsTrue() + { + var outer = new PlanNode { PhysicalOp = "Sort" }; + var spool = new PlanNode { PhysicalOp = "Index Spool" }; + var scan = new PlanNode { PhysicalOp = "Clustered Index Scan" }; + var nl = new PlanNode { PhysicalOp = "Nested Loops", Children = { outer, spool } }; + outer.Parent = nl; + spool.Parent = nl; + spool.Children.Add(scan); + scan.Parent = spool; + + Assert.True(RowEstimateHelper.IsInnerSideOfNestedLoops(scan)); + } + + /// + /// Nested loops within loops: a node under the inner side of an inner Nested Loops, which is + /// itself the outer side of an outer Nested Loops, is still inner-side because of the inner + /// join — being on the outer side of the outer join does not cancel that out. + /// + [Fact] + public void IsInnerSide_NestedLoopsWithinLoops_WalksThroughEveryAncestor() + { + var deepInner = new PlanNode { PhysicalOp = "Key Lookup" }; + var innerNlOuter = new PlanNode { PhysicalOp = "Index Seek" }; + var innerNl = new PlanNode { PhysicalOp = "Nested Loops", Children = { innerNlOuter, deepInner } }; + innerNlOuter.Parent = innerNl; + deepInner.Parent = innerNl; + + var outerNlOuter = new PlanNode { PhysicalOp = "Clustered Index Scan" }; + var outerNl = new PlanNode { PhysicalOp = "Nested Loops", Children = { outerNlOuter, innerNl } }; + outerNlOuter.Parent = outerNl; + innerNl.Parent = outerNl; + + // innerNl is the outer join's inner (second) child, so everything under it is inner-side — + // including deepInner, which is also the inner join's own inner child. + Assert.True(RowEstimateHelper.IsInnerSideOfNestedLoops(innerNl)); + Assert.True(RowEstimateHelper.IsInnerSideOfNestedLoops(deepInner)); + // outerNlOuter is the outer join's outer child and has no other Nested Loops ancestor. + Assert.False(RowEstimateHelper.IsInnerSideOfNestedLoops(outerNlOuter)); + } + + // --------------------------------------------------------------- + // GetExpectedRows / GetRowAccuracyRatio + // --------------------------------------------------------------- + + [Fact] + public void GetExpectedRows_NonInnerSide_IsNotMultipliedByExecutions() + { + // A parallel zone's ActualExecutions counts threads, not repeats, for a node that isn't + // itself repeated by a loop. + var node = new PlanNode { PhysicalOp = "Nested Loops", EstimateRows = 2983.02, ActualExecutions = 8 }; + + Assert.Equal(2983.02, RowEstimateHelper.GetExpectedRows(node), 6); + } + + [Fact] + public void GetExpectedRows_InnerSide_IsMultipliedByExecutions() + { + var outer = new PlanNode { PhysicalOp = "Sort" }; + var inner = new PlanNode { PhysicalOp = "Top", EstimateRows = 1, ActualExecutions = 613 }; + var nl = new PlanNode { PhysicalOp = "Nested Loops", Children = { outer, inner } }; + outer.Parent = nl; + inner.Parent = nl; + + Assert.Equal(613, RowEstimateHelper.GetExpectedRows(inner), 6); + } + + /// + /// #594's core guard: an inner-side node that never executed does not get its (irrelevant) + /// estimate multiplied by zero. + /// + [Fact] + public void GetExpectedRows_InnerSideButNeverExecuted_FallsBackToRawEstimate() + { + var outer = new PlanNode { PhysicalOp = "Sort" }; + var inner = new PlanNode { PhysicalOp = "Index Seek", EstimateRows = 5, ActualExecutions = 0 }; + var nl = new PlanNode { PhysicalOp = "Nested Loops", Children = { outer, inner } }; + outer.Parent = nl; + inner.Parent = nl; + + Assert.Equal(5, RowEstimateHelper.GetExpectedRows(inner), 6); + } + + [Fact] + public void GetRowAccuracyRatio_ZeroExpectedAndZeroActual_IsNeutral() + { + var node = new PlanNode { EstimateRows = 0, ActualRows = 0 }; + + Assert.Equal(1.0, RowEstimateHelper.GetRowAccuracyRatio(node)); + } + + [Fact] + public void GetRowAccuracyRatio_ZeroExpectedButSomeActual_IsUnbounded() + { + var node = new PlanNode { EstimateRows = 0, ActualRows = 100 }; + + Assert.Equal(double.MaxValue, RowEstimateHelper.GetRowAccuracyRatio(node)); + } + + // --------------------------------------------------------------- + // eager_index_spool_plan.sqlplan — the coordinator's worked numbers (#594) + // --------------------------------------------------------------- + + private static PlanNode Node(string planFile, int nodeId) + { + var plan = PlanTestHelper.LoadAndAnalyze(planFile); + var root = plan.Batches[0].Statements[0].RootNode!; + var node = PlanTestHelper.FindNode(root, nodeId); + Assert.NotNull(node); + return node!; + } + + /// + /// Node 1, the outer Nested Loops: not inner-side. 609 actual over an EstimateRows of + /// 2983.02 is a 4.9x overestimate (20%) — not the 39x a thread-summed ActualExecutions of 8 + /// would give if it were multiplied in. + /// + [Fact] + public void EagerIndexSpoolPlan_Node1_NotInnerSide_RatioIsFourPointNineX() + { + var node1 = Node("eager_index_spool_plan.sqlplan", 1); + + Assert.False(RowEstimateHelper.IsInnerSideOfNestedLoops(node1)); + Assert.Equal(8, node1.ActualExecutions); + Assert.Equal(609, node1.ActualRows); + Assert.Equal(2983.02, RowEstimateHelper.GetExpectedRows(node1), 2); + + var ratio = RowEstimateHelper.GetRowAccuracyRatio(node1); + Assert.Equal(0.204, ratio, 3); + Assert.Equal(4.9, 1.0 / ratio, 1); + } + + /// + /// Nodes 4 and 5, Top and Index Spool: both inner-side of Node 1. 609 actual over 613 + /// executions against an EstimateRows of 1 is 99% — the estimate was right. + /// + [Theory] + [InlineData(4)] + [InlineData(5)] + public void EagerIndexSpoolPlan_InnerSideNodes_RatioIsNinetyNinePercent(int nodeId) + { + var node = Node("eager_index_spool_plan.sqlplan", nodeId); + + Assert.True(RowEstimateHelper.IsInnerSideOfNestedLoops(node)); + Assert.Equal(613, node.ActualExecutions); + Assert.Equal(609, node.ActualRows); + Assert.Equal(613, RowEstimateHelper.GetExpectedRows(node), 6); + + var ratio = RowEstimateHelper.GetRowAccuracyRatio(node); + Assert.Equal(0.993, ratio, 3); + } + + /// + /// Node 6, the Clustered Index Scan under the spool: also inner-side, but executed once, so + /// multiplying by ActualExecutions is a no-op and the expected rows equal the raw estimate. + /// + [Fact] + public void EagerIndexSpoolPlan_Node6_InnerSideButSingleExecution_MatchesRawEstimate() + { + var node6 = Node("eager_index_spool_plan.sqlplan", 6); + + Assert.True(RowEstimateHelper.IsInnerSideOfNestedLoops(node6)); + Assert.Equal(1, node6.ActualExecutions); + Assert.Equal(8_042_005, node6.ActualRows); + Assert.Equal(8_042_010, RowEstimateHelper.GetExpectedRows(node6), 6); + } +} diff --git a/tests/PlanViewer.Core.Tests/RuntimeSummaryMemoryGrantTests.cs b/tests/PlanViewer.Core.Tests/RuntimeSummaryMemoryGrantTests.cs new file mode 100644 index 00000000..4c8b6f67 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/RuntimeSummaryMemoryGrantTests.cs @@ -0,0 +1,78 @@ +using System.IO; +using System.Linq; +using Avalonia.Controls; +using Avalonia.LogicalTree; +using Avalonia.Media; +using PlanViewer.App.Controls; + +namespace PlanViewer.Core.Tests; + +/// +/// #595: a statement whose MemoryGrantInfo reports GrantedMemory="0" showed "0 KB granted, 0 KB +/// used (100%)" in the Runtime Summary — a percentage of nothing, styled the same as an operator +/// that used every byte of a real grant. Expected: say there was no grant, with no percentage, +/// in the same neutral color as every other line that isn't flagging a problem. +/// +public class RuntimeSummaryMemoryGrantTests +{ + [Fact] + public void NoMemoryGrant_ShowsNeutralLineWithNoPercentage() + { + HeadlessUi.Run(() => + { + var viewer = LoadPlanWithZeroMemoryGrant(); + var window = new Window { Content = viewer, Width = 1400, Height = 900 }; + window.Show(); + window.UpdateLayout(); + + var grid = SummaryGrid(viewer); + var (grantText, grantForeground) = Row(grid, "Memory grant"); + var (_, elapsedForeground) = Row(grid, "Elapsed"); + + Assert.DoesNotContain("%", grantText); + Assert.Contains("No memory grant", grantText); + // Neutral, same as every other row that passes no brush key — not the WarningBrush + // the old "0 grant reads as 100% used" math would have colored it, since this + // fixture's tree really did spill (on the Sort feeding the grant). + Assert.Equal(elapsedForeground, grantForeground); + }); + } + + /// + /// memory_grant_wait_plan.sqlplan already has a real MemoryGrantInfo and a real spill + /// (SpillToTempDb on a Sort). Zeroing only the grant/used-memory attributes keeps the spill, + /// which is what makes the neutral-color assertion meaningful instead of trivially true. + /// + private static PlanViewerControl LoadPlanWithZeroMemoryGrant() + { + var path = Path.Combine("Plans", "memory_grant_wait_plan.sqlplan"); + Assert.True(File.Exists(path), $"Test plan not found: {path}"); + var xml = File.ReadAllText(path).Replace("encoding=\"utf-16\"", "encoding=\"utf-8\""); + + const string granted = "GrantedMemory=\"10851312\""; + const string maxUsed = "MaxUsedMemory=\"10232840\""; + Assert.Contains(granted, xml); + Assert.Contains(maxUsed, xml); + xml = xml.Replace(granted, "GrantedMemory=\"0\"").Replace(maxUsed, "MaxUsedMemory=\"0\""); + + var viewer = new PlanViewerControl(); + Assert.True(viewer.LoadPlan(xml, "memory_grant_wait_plan.sqlplan"), $"Plan failed to load: {viewer.LastLoadError}"); + return viewer; + } + + private static Grid SummaryGrid(PlanViewerControl viewer) + { + var panel = viewer.GetLogicalDescendants().OfType().First(p => p.Name == "RuntimeSummaryContent"); + return (Grid)panel.Children.Single(); + } + + private static (string Text, IBrush Foreground) Row(Grid grid, string label) + { + var labelBlock = grid.Children.OfType() + .First(t => Grid.GetColumn(t) == 0 && t.Text == label); + var row = Grid.GetRow(labelBlock); + var valueBlock = grid.Children.OfType() + .First(t => Grid.GetColumn(t) == 1 && Grid.GetRow(t) == row); + return (valueBlock.Text ?? "", valueBlock.Foreground!); + } +} diff --git a/tests/PlanViewer.Core.Tests/WarningBaseline.txt b/tests/PlanViewer.Core.Tests/WarningBaseline.txt index 18794195..8fa3ae41 100644 --- a/tests/PlanViewer.Core.Tests/WarningBaseline.txt +++ b/tests/PlanViewer.Core.Tests/WarningBaseline.txt @@ -37,7 +37,6 @@ Wait: EXECSYNC | Critical | EXECSYNC Observed 123,292 ms across 7 waits. Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 777 ms across 4,263 waits. Wait: CXSYNC_PORT | Critical | CXSYNC_PORT Observed 41 ms across 9 waits. Wait: MEMORY_ALLOCATION_EXT | Info | MEMORY_ALLOCATION_EXT Observed 9 ms across 24,330 waits. -Row Estimate Mismatch | Warning | Estimated 2,983 vs Actual 609 (76 rows x 8 executions) — 39x overestimated. The overestimate may have caused the optimizer to make poor choices. Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Only 0.025% of rows survived filtering (613 of 2,465,713). Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Users].[Reputation] as [u].[Reputation]>(100000) Eager Index Spool | Critical | SQL Server is building a temporary index in TempDB at runtime because no suitable permanent index exists. This is expensive — it builds the index from scratch on every execution. Create a permanent index on the underlying table to eliminate this operator entirely.\n\nCreate this index:\nCREATE INDEX [UserId] ON dbo.Badges (UserId) INCLUDE (Name); Parallel Skew | Warning | Thread 3 processed 100% of rows (8,042,005/8,042,005). Work is heavily skewed to one thread, so parallelism isn't helping much. Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help. @@ -180,9 +179,9 @@ Wait: MEMORY_ALLOCATION_EXT | Info | MEMORY_ALLOCATION_EXT Observed 256 ms acros Wait: PAGEIOLATCH_SH | Info | PAGEIOLATCH_SH Observed 225 ms across 690 waits. Effective latency: 326 µs per wait. Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 83 ms across 1,268 waits. Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Only 0.236% of rows survived filtering (5,829 of 2,465,713). Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Users].[Reputation] as [u].[Reputation]>=(20000) -Row Estimate Mismatch | Critical | Estimated 8,820,150 vs Actual 5,723 (715 rows x 8 executions) — 12329x overestimated. The overestimate may have caused the optimizer to make poor choices. +Row Estimate Mismatch | Critical | Estimated 8,820,150 vs Actual 5,723 — 1541x overestimated. The overestimate may have caused the optimizer to make poor choices. Filter Operator | Warning | Filter operator discarding rows late in the plan.\n• 4,184,508 logical reads below\nPredicate: [Expr1002]=(1) -Row Estimate Mismatch | Critical | Estimated 8,820,150 vs Actual 569,368 (71,171 rows x 8 executions) — 124x overestimated. The overestimate may have caused the optimizer to make poor choices. +Row Estimate Mismatch | Warning | Estimated 8,820,150 vs Actual 569,368 — 15x overestimated. The overestimate may have caused the optimizer to make poor choices. Sort Spill | Critical | Sort spill level 1, 8 thread(s) — Granted: 10,815,552 KB, Used: 10,220,984 KB, Writes: 1,512,936, Reads: 1,512,936 Operator time: 25,148ms (94% of statement). Expensive Operator | Warning | Parallelism took 12,362ms (46.1% 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? Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Posts].[PostTypeId] as [p].[PostTypeId]=(2) AND [StackOverflow2013].[dbo].[Posts].[Score] as [p].[Score]>(0) @@ -289,7 +288,7 @@ Parallel Skew | Warning | Thread 3 processed 100% of rows (53,946/53,946). Work Parallel Skew | Warning | Thread 4 processed 100% of rows (47,575/47,575). Work is heavily skewed to one thread, so parallelism isn't helping much. Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help. Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. This scan is 52% of the plan cost. This scan took 53% of elapsed time. Only 0.278% of rows survived filtering (47,575 of 17,142,169). Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Posts].[PostTypeId] as [p].[PostTypeId]=(2) AND [StackOverflow2013].[dbo].[Posts].[CreationDate] as [p].[CreationDate]>='2013-12-25 00:00:00.000' Filter Operator | Warning | Filter operator discarding rows late in the plan.\n• 52,874,774 of 52,928,720 rows discarded (100%)\n• 243,866 logical reads below\n• 1,719ms elapsed below\nPredicate: PROBE([Opt_Bitmap1006],[StackOverflow2013].[dbo].[Votes].[PostId] as [v].[PostId]) -Row Estimate Mismatch | Critical | Estimated 5,292,870 vs Actual 53,946 (13,486 rows x 4 executions) — 392x overestimated. The overestimate may have caused the optimizer to make poor choices. +Row Estimate Mismatch | Warning | Estimated 5,292,870 vs Actual 53,946 — 98x overestimated. The overestimate may have caused the optimizer to make poor choices. Parallel Skew | Warning | Thread 3 processed 100% of rows (53,946/53,946). Work is heavily skewed to one thread, so parallelism isn't helping much. Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help. Bare Scan | Warning | Clustered index scan reads the full table with no predicate, outputting 1 column(s): Votes.PostId. Consider a nonclustered index on the output columns (as key or INCLUDE) so SQL Server can read a narrower structure. For analytical workloads, a columnstore index may be a better fit. @@ -347,7 +346,7 @@ Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 13 ms across 4,2 Wait: CXSYNC_CONSUMER | Critical | CXSYNC_CONSUMER Observed 2 ms across 14 waits. Expensive Operator | Critical | Sort took 17,111ms (100.0% 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? Expensive Operator | Critical | Parallelism took 17,111ms (100.0% 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? -Row Estimate Mismatch | Critical | Estimated 3,279 vs Actual 21 (3 rows x 8 executions) — 1249x overestimated. The overestimate may have caused the optimizer to make poor choices. +Row Estimate Mismatch | Critical | Estimated 3,279 vs Actual 21 — 156x overestimated. The overestimate may have caused the optimizer to make poor choices. Parallel Skew | Warning | Thread 1 processed 100% of rows (27,026/27,026). Work is heavily skewed to one thread, so parallelism isn't helping much. Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help. Nested Loops High Executions | Critical | Nested Loops inner side executed 10,810,787 times (DOP 8). Inner side total: 41,216,099 logical reads. Inner side time: 10,156ms (59% of statement). Consider whether a hash or merge join would be more appropriate for this row count. Parallel Skew | Warning | Thread 1 processed 100% of rows (10,810,787/10,810,787). Work is heavily skewed to one thread, so parallelism isn't helping much. Common causes: uneven data distribution across partitions or hash buckets, or a scan/seek whose predicate sends most rows to one range. Reducing DOP or rewriting the query to avoid the skewed operation may help. @@ -361,7 +360,6 @@ Row Estimate Mismatch | Critical | Estimated 1 vs Actual 21 (0 rows x 27,026 exe Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 199 ms across 37,744 waits. Wait: CXSYNC_PORT | Info | CXSYNC_PORT Observed 1 ms across 9 waits. Filter Operator | Warning | Filter operator discarding rows late in the plan.\n• 28,425,793 of 28,425,793 rows discarded (100%)\n• 133,228,207 logical reads below\n• 19,811ms elapsed below\nPredicate: [Expr1002]=(0) -Row Estimate Mismatch | Warning | Estimated 36,946,200 vs Actual 28,425,793 (3,553,224 rows x 8 executions) — 10x overestimated. The overestimate may have caused the optimizer to make poor choices. Nested Loops High Executions | Critical | Nested Loops inner side executed 11,091,349 times (DOP 8). Inner side total: 133,212,966 logical reads. Inner side time: 19,039ms (95% of statement). Consider whether a hash or merge join would be more appropriate for this row count. Expensive Operator | Critical | Index Seek took 17,805ms (89.2% 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? @@ -379,9 +377,9 @@ Wait: MEMORY_ALLOCATION_EXT | Info | MEMORY_ALLOCATION_EXT Observed 256 ms acros Wait: PAGEIOLATCH_SH | Info | PAGEIOLATCH_SH Observed 225 ms across 690 waits. Effective latency: 326 µs per wait. Wait: SOS_SCHEDULER_YIELD | Info | SOS_SCHEDULER_YIELD Observed 83 ms across 1,268 waits. Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Only 0.236% of rows survived filtering (5,829 of 2,465,713). Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Users].[Reputation] as [u].[Reputation]>=(20000) -Row Estimate Mismatch | Critical | Estimated 8,820,150 vs Actual 5,723 (715 rows x 8 executions) — 12329x overestimated. The overestimate may have caused the optimizer to make poor choices. +Row Estimate Mismatch | Critical | Estimated 8,820,150 vs Actual 5,723 — 1541x overestimated. The overestimate may have caused the optimizer to make poor choices. Filter Operator | Warning | Filter operator discarding rows late in the plan.\n• 4,184,508 logical reads below\nPredicate: [Expr1002]=(1) -Row Estimate Mismatch | Critical | Estimated 8,820,150 vs Actual 569,368 (71,171 rows x 8 executions) — 124x overestimated. The overestimate may have caused the optimizer to make poor choices. +Row Estimate Mismatch | Warning | Estimated 8,820,150 vs Actual 569,368 — 15x overestimated. The overestimate may have caused the optimizer to make poor choices. Sort Spill | Critical | Sort spill level 1, 8 thread(s) — Granted: 10,815,552 KB, Used: 10,220,984 KB, Writes: 1,512,936, Reads: 1,512,936 Operator time: 25,148ms (94% of statement). Expensive Operator | Warning | Parallelism took 12,362ms (46.1% 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? Scan With Predicate | Warning | Scan with residual predicate — SQL Server is reading every row and filtering after the fact. Check that you have appropriate indexes.\nPredicate: [StackOverflow2013].[dbo].[Posts].[PostTypeId] as [p].[PostTypeId]=(2) AND [StackOverflow2013].[dbo].[Posts].[Score] as [p].[Score]>(0) From 74501ce78d51f5c02bced04fa61bceb9d34a423b Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:55:35 -0400 Subject: [PATCH 26/85] Use the execution-aware estimate in the HTML export too The export writes the same "N of M rows" line as the node label, so it now reads ExpectedRows, a new operator result field (expected_rows in the JSON output) set from RowEstimateHelper. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- src/PlanViewer.Core/Output/AnalysisResult.cs | 7 +++++++ src/PlanViewer.Core/Output/HtmlExporter.cs | 3 ++- src/PlanViewer.Core/Output/ResultMapper.cs | 1 + .../RowEstimateHelperTests.cs | 19 +++++++++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) 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 33b1cff5..4aad7cc6 100644 --- a/src/PlanViewer.Core/Output/HtmlExporter.cs +++ b/src/PlanViewer.Core/Output/HtmlExporter.cs @@ -572,7 +572,8 @@ private static void WriteOperatorLine(StringBuilder sb, OperatorResult node) // Rows if (node.ActualRows.HasValue) { - var est = node.EstimatedRows; + // #594: the same execution-aware estimate the plan viewer's node label shows. + var est = node.ExpectedRows ?? 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}"); diff --git a/src/PlanViewer.Core/Output/ResultMapper.cs b/src/PlanViewer.Core/Output/ResultMapper.cs index 570dea38..24983ddd 100644 --- a/src/PlanViewer.Core/Output/ResultMapper.cs +++ b/src/PlanViewer.Core/Output/ResultMapper.cs @@ -366,6 +366,7 @@ private static OperatorResult MapSingleNode(PlanNode node, CancellationToken can { 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; diff --git a/tests/PlanViewer.Core.Tests/RowEstimateHelperTests.cs b/tests/PlanViewer.Core.Tests/RowEstimateHelperTests.cs index ce8ed2c5..0de983d2 100644 --- a/tests/PlanViewer.Core.Tests/RowEstimateHelperTests.cs +++ b/tests/PlanViewer.Core.Tests/RowEstimateHelperTests.cs @@ -227,4 +227,23 @@ public void EagerIndexSpoolPlan_Node6_InnerSideButSingleExecution_MatchesRawEsti Assert.Equal(8_042_005, node6.ActualRows); Assert.Equal(8_042_010, RowEstimateHelper.GetExpectedRows(node6), 6); } + + /// + /// The HTML export (the web viewer's download) writes the same "N of M rows" line as the + /// node label, so it takes the same estimate: the join reads against its own estimate, the + /// inner-side spool against its estimate times its executions. + /// + [Fact] + public void EagerIndexSpoolPlan_HtmlExport_UsesTheExecutionAwareEstimate() + { + const string plan = "eager_index_spool_plan.sqlplan"; + var result = PlanViewer.Core.Output.ResultMapper.Map(PlanTestHelper.LoadAndAnalyze(plan), plan); + + var html = PlanViewer.Core.Output.HtmlExporter.Export( + result, PlanViewer.Core.Output.TextFormatter.Format(result)); + + Assert.Contains("609 of 2,983 rows (20%)", html); + Assert.Contains("609 of 613 rows (99%)", html); + Assert.DoesNotContain("609 of 1 rows", html); + } } From 612075029b4bd622be5c4fc042f77be0e5cbf769 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:13:12 -0400 Subject: [PATCH 27/85] Quarantine unreadable/corrupt settings files instead of overwriting them, and flush AtomicFile writes to disk Three stores (AppSettingsService, ConnectionStore, SettingsFile) returned defaults whenever their file could not be read or parsed, and the next save silently wrote those defaults over the user's real file. A shared helper, SettingsFileStore, now tells the two failure modes apart: a file that fails to parse (or parses to the wrong shape, like SettingsFile's array-vs-object case) is moved aside to a .bad- sibling and saves resume normally; a file that fails to read at all is left untouched and saves are refused until a later read of it succeeds. AppSettingsService.Save already swallowed write failures and now swallows this one the same way; ConnectionStore.Save and SettingsFile.Update throw an IOException naming the file instead, since their callers need to know why nothing was written. ConnectionStore gained a RedirectForTestHost seam, matching the other two stores, so tests never touch the real ~/.planview files. AtomicFile.WriteAllText wrote its .tmp with File.WriteAllText and renamed it without ever flushing to disk, so a power loss right after a "successful" save could still leave the renamed file empty. It now writes through a FileStream and calls Flush(flushToDisk: true) before the rename, with the same preamble/no-preamble handling File.WriteAllText always had, so the on-disk bytes are unchanged for a null encoding, an explicit BOM-emitting one, and a legacy single-byte code page. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Services/AppSettingsService.cs | 55 +++- src/PlanViewer.App/Services/AtomicFile.cs | 34 ++- .../Services/ConnectionStore.cs | 61 +++- src/PlanViewer.App/Services/SettingsFile.cs | 46 +++- .../Services/SettingsFileStore.cs | 127 +++++++++ .../PlanViewer.Core.Tests/AtomicFileTests.cs | 110 ++++++++ tests/PlanViewer.Core.Tests/HeadlessUi.cs | 4 + .../SettingsFileStoreTests.cs | 260 ++++++++++++++++++ .../SettingsIntegrationsTests.cs | 7 + 9 files changed, 659 insertions(+), 45 deletions(-) create mode 100644 src/PlanViewer.App/Services/SettingsFileStore.cs create mode 100644 tests/PlanViewer.Core.Tests/AtomicFileTests.cs create mode 100644 tests/PlanViewer.Core.Tests/SettingsFileStoreTests.cs diff --git a/src/PlanViewer.App/Services/AppSettingsService.cs b/src/PlanViewer.App/Services/AppSettingsService.cs index c3180b5d..2890e6ec 100644 --- a/src/PlanViewer.App/Services/AppSettingsService.cs +++ b/src/PlanViewer.App/Services/AppSettingsService.cs @@ -24,6 +24,17 @@ 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. Cleared by the next that manages to read the file (which + /// happens on every call while this is true, since a blocked Load never populates + /// — see the "until a later read of it succeeds" contract on Load). + /// + private static bool _saveBlocked; + static AppSettingsService() { SettingsDir = Path.Combine( @@ -69,8 +80,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 +102,17 @@ 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 until one succeeds, which is what lets resume once a + /// transient lock clears. /// public static AppSettings Load() { @@ -104,14 +121,17 @@ public static AppSettings Load() try { - AppSettings settings; - if (!File.Exists(SettingsPath)) - settings = new AppSettings(); - else - { - var json = File.ReadAllText(SettingsPath); - settings = JsonSerializer.Deserialize(json, JsonOptions) ?? new AppSettings(); - } + var outcome = SettingsFileStore.Read( + SettingsPath, + nameof(AppSettingsService), + json => JsonSerializer.Deserialize(json, JsonOptions), + out var parsed); + + _saveBlocked = outcome == SettingsFileStore.ReadOutcome.Unreadable; + if (outcome == SettingsFileStore.ReadOutcome.Unreadable) + 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 @@ -149,10 +169,15 @@ public static AppSettings Load() public static void Invalidate() => _cached = null; /// - /// Saves settings to disk. Silently ignores write failures. + /// 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. /// public static void Save(AppSettings settings) { + if (_saveBlocked) + return; + try { Directory.CreateDirectory(SettingsDir); diff --git a/src/PlanViewer.App/Services/AtomicFile.cs b/src/PlanViewer.App/Services/AtomicFile.cs index 581b0bda..bf0cdb77 100644 --- a/src/PlanViewer.App/Services/AtomicFile.cs +++ b/src/PlanViewer.App/Services/AtomicFile.cs @@ -10,6 +10,13 @@ namespace PlanViewer.App.Services; /// internal static class AtomicFile { + /// + /// Matches File.WriteAllText's own default encoding: UTF-8, and — unlike the + /// singleton — with an empty preamble, so passing no encoding + /// below writes no BOM, exactly as it always has. + /// + private static readonly UTF8Encoding DefaultEncoding = new(encoderShouldEmitUTF8Identifier: false); + /// /// Writes to atomically /// with respect to process crashes. If the process dies before the rename, @@ -24,11 +31,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..c9cf36e9 100644 --- a/src/PlanViewer.App/Services/ConnectionStore.cs +++ b/src/PlanViewer.App/Services/ConnectionStore.cs @@ -9,34 +9,69 @@ 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 }; + /// + /// Set by when exists but could not even be read + /// (locked, permissions). refuses to write while this is set — see + /// for why, and 's matching + /// field for how the block clears on a later successful read. + /// + 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) + { + ConfigDir = directory; + ConfigFile = Path.Combine(directory, "connections.json"); + _saveBlocked = false; + } + + /// + /// 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() { - if (!File.Exists(ConfigFile)) - return new List(); + var outcome = SettingsFileStore.Read>( + ConfigFile, + nameof(ConnectionStore), + json => JsonSerializer.Deserialize>(json), + out var parsed); - try - { - var json = File.ReadAllText(ConfigFile); - return JsonSerializer.Deserialize>(json) ?? new List(); - } - catch - { - return new List(); - } + _saveBlocked = outcome == SettingsFileStore.ReadOutcome.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); diff --git a/src/PlanViewer.App/Services/SettingsFile.cs b/src/PlanViewer.App/Services/SettingsFile.cs index 184b7c52..eb59c3d5 100644 --- a/src/PlanViewer.App/Services/SettingsFile.cs +++ b/src/PlanViewer.App/Services/SettingsFile.cs @@ -9,6 +9,15 @@ internal static class SettingsFile { public static string Path { get; private set; } = DefaultPath(); + /// + /// Set by when exists but could not even be read + /// (locked, permissions). refuses to write while this is set — see + /// for why, and 's matching + /// field for how the block clears on a later successful read. Update always calls Read + /// first (it's a read-modify-write), so every Update attempt is itself a retry. + /// + private static bool _updateBlocked; + private static string DefaultPath() => System.IO.Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".planview", "settings.json"); @@ -23,28 +32,39 @@ 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"); + _updateBlocked = false; + } public static JsonObject Read() { - 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); + + _updateBlocked = 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. + /// public static void Update(Action mutate) { var obj = Read(); + if (_updateBlocked) + 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..ecf56a6a --- /dev/null +++ b/src/PlanViewer.App/Services/SettingsFileStore.cs @@ -0,0 +1,127 @@ +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 . + /// + private static bool Quarantine(string path, string logSource) + { + try + { + var quarantined = $"{path}.bad-{DateTime.UtcNow:yyyyMMddHHmmss}"; + File.Move(path, quarantined, overwrite: false); + Debug.WriteLine($"{logSource}: moved unreadable {path} to {quarantined}"); + 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/tests/PlanViewer.Core.Tests/AtomicFileTests.cs b/tests/PlanViewer.Core.Tests/AtomicFileTests.cs new file mode 100644 index 00000000..06fffa34 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/AtomicFileTests.cs @@ -0,0 +1,110 @@ +using System.IO; +using System.Text; +using PlanViewer.App.Services; + +namespace PlanViewer.Core.Tests; + +/// +/// AtomicFile stages its write through a sibling .tmp and renames it into place so a +/// crash mid-write can't truncate the target file. That guarantee had a gap: the .tmp was +/// written with File.WriteAllText and renamed without ever being flushed to disk, so the bytes +/// could still be sitting in the OS write-behind cache when the rename landed — a power loss +/// right after a "successful" save could still leave the renamed file empty. These pin that the +/// fix (a FileStream, explicit encoding-preamble handling, and Flush(flushToDisk: true) before +/// the rename) writes the exact same bytes File.WriteAllText always did, for a null encoding, an +/// explicit BOM-emitting one, and a legacy single-byte code page. +/// +public class AtomicFileTests +{ + private const string Contents = "SELECT 1; -- café"; + + [Fact] + public void NullEncodingMatchesFileWriteAllTextsDefaultUtf8NoBom() + { + var path = TempPath(); + var expectedPath = TempPath(); + try + { + AtomicFile.WriteAllText(path, Contents); + File.WriteAllText(expectedPath, Contents); + + Assert.Equal(File.ReadAllBytes(expectedPath), File.ReadAllBytes(path)); + Assert.NotEqual(0xEF, File.ReadAllBytes(path)[0]); // no BOM + Assert.False(File.Exists(path + ".tmp"), "the staging file must not linger"); + } + finally + { + File.Delete(path); + File.Delete(expectedPath); + } + } + + [Fact] + public void ExplicitBomEncodingMatchesFileWriteAllTextsPreamble() + { + var path = TempPath(); + var expectedPath = TempPath(); + try + { + AtomicFile.WriteAllText(path, Contents, Encoding.UTF8); // the BOM-emitting singleton + File.WriteAllText(expectedPath, Contents, Encoding.UTF8); + + var bytes = File.ReadAllBytes(path); + Assert.Equal(File.ReadAllBytes(expectedPath), bytes); + Assert.Equal(new byte[] { 0xEF, 0xBB, 0xBF }, bytes[..3]); + Assert.False(File.Exists(path + ".tmp")); + } + finally + { + File.Delete(path); + File.Delete(expectedPath); + } + } + + /// + /// Latin-1 (ISO-8859-1 / code page 28591) rather than a Windows ANSI code page: it is a + /// legacy single-byte encoding with no preamble, built into the runtime since .NET 5, and — + /// unlike code page 1252 — needs no System.Text.Encoding.CodePages provider, so it behaves + /// identically on the Ubuntu CI runner and here. + /// + [Fact] + public void LegacyCodePageMatchesFileWriteAllTexts() + { + var path = TempPath(); + var expectedPath = TempPath(); + try + { + AtomicFile.WriteAllText(path, Contents, Encoding.Latin1); + File.WriteAllText(expectedPath, Contents, Encoding.Latin1); + + Assert.Equal(File.ReadAllBytes(expectedPath), File.ReadAllBytes(path)); + Assert.False(File.Exists(path + ".tmp")); + } + finally + { + File.Delete(path); + File.Delete(expectedPath); + } + } + + [Fact] + public void OverwritingAnExistingFileStillRoundTrips() + { + var path = TempPath(); + try + { + AtomicFile.WriteAllText(path, "first"); + AtomicFile.WriteAllText(path, Contents); + + Assert.Equal(Contents, File.ReadAllText(path)); + Assert.False(File.Exists(path + ".tmp")); + } + finally + { + File.Delete(path); + } + } + + private static string TempPath() => + Path.Combine(Path.GetTempPath(), $"{Path.GetRandomFileName()}.atomicfiletest"); +} diff --git a/tests/PlanViewer.Core.Tests/HeadlessUi.cs b/tests/PlanViewer.Core.Tests/HeadlessUi.cs index a7df54ab..046b663a 100644 --- a/tests/PlanViewer.Core.Tests/HeadlessUi.cs +++ b/tests/PlanViewer.Core.Tests/HeadlessUi.cs @@ -121,6 +121,10 @@ internal static void EnterTestHostMode() // Integrations writes. Redirected for the same reason as the line above. SettingsFile.RedirectForTestHost(SettingsRedirectRoot); + // The saved server list. Redirected for the same reason — #F1 added this seam; before + // it, ConnectionStore's path was a plain static readonly field under the real profile. + ConnectionStore.RedirectForTestHost(SettingsRedirectRoot); + // And the third store. Proxy passwords live in the OS credential manager, not in either // JSON file, so redirecting those two still left a test able to read — or delete — the // developer's real saved credential. diff --git a/tests/PlanViewer.Core.Tests/SettingsFileStoreTests.cs b/tests/PlanViewer.Core.Tests/SettingsFileStoreTests.cs new file mode 100644 index 00000000..261fcf01 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/SettingsFileStoreTests.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.IO; +using PlanViewer.App.Services; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Tests; + +/// +/// The shared corrupt/unreadable-file policy () as it applies to +/// each of the three stores that use it. Before this policy existed, all three treated "can't +/// parse" and "can't read" the same as "does not exist" — defaults came back, and the very next +/// save silently overwrote the user's file with those defaults. That is exactly backward for a +/// read failure that might be transient: the file deserves a chance to be read again, not to be +/// clobbered with a guess. +/// +/// Every test writes and locks only the redirected files under +/// — see the module initializer that sets that up +/// for the whole run. None of this touches the developer's real profile. +/// +[Collection("SettingsFileStore serial")] +public class SettingsFileStoreTests +{ + // ── AppSettingsService ────────────────────────────────────────────── + + [Fact] + public void MalformedAppSettingsIsQuarantinedAndDefaultsComeBackAndSavesWork() + { + var path = AppSettingsService.SettingsFilePath; + File.WriteAllText(path, "{ not valid json"); + AppSettingsService.Invalidate(); + + var settings = AppSettingsService.Load(); + Assert.Equal(30, settings.QueryStoreSlicerDays); // the default — the broken file must not throw + + Assert.False(File.Exists(path), "the unparseable file must be moved aside, not left in place"); + var quarantined = FindQuarantineFiles(path); + Assert.Single(quarantined); + Assert.Contains("not valid json", File.ReadAllText(quarantined[0])); + + // Saves work normally right after — quarantining is not the same as being blocked. + settings.QueryStoreSlicerDays = 77; + AppSettingsService.Save(settings); + AppSettingsService.Invalidate(); + Assert.Equal(77, AppSettingsService.Load().QueryStoreSlicerDays); + + File.Delete(quarantined[0]); + } + + [Fact] + public void MissingAppSettingsFileIsDefaultsAndSavesWork() + { + var path = AppSettingsService.SettingsFilePath; + if (File.Exists(path)) + File.Delete(path); + AppSettingsService.Invalidate(); + + var settings = AppSettingsService.Load(); + Assert.Equal(30, settings.QueryStoreSlicerDays); + + settings.QueryStoreSlicerDays = 55; + AppSettingsService.Save(settings); + AppSettingsService.Invalidate(); + Assert.Equal(55, AppSettingsService.Load().QueryStoreSlicerDays); + } + + [Fact] + public void UnreadableAppSettingsRefusesToSaveUntilALaterReadSucceeds() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), + "Unix permissions don't reliably block a same-user read the way FileShare.None does on Windows."); + + var path = AppSettingsService.SettingsFilePath; + const string original = """{"query_store_slicer_days": 12}"""; + File.WriteAllText(path, original); + AppSettingsService.Invalidate(); + + using (new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None)) + { + var blocked = AppSettingsService.Load(); + Assert.Equal(30, blocked.QueryStoreSlicerDays); // couldn't read the real 12 — defaults + + blocked.QueryStoreSlicerDays = 999; + AppSettingsService.Save(blocked); // must not throw, must not touch the locked file + } + + Assert.Equal(original, File.ReadAllText(path)); + + // The lock is gone — a later read succeeds, the block clears, and saves resume. + AppSettingsService.Invalidate(); + var reloaded = AppSettingsService.Load(); + Assert.Equal(12, reloaded.QueryStoreSlicerDays); + + reloaded.QueryStoreSlicerDays = 88; + AppSettingsService.Save(reloaded); + AppSettingsService.Invalidate(); + Assert.Equal(88, AppSettingsService.Load().QueryStoreSlicerDays); + } + + // ── ConnectionStore ────────────────────────────────────────────────── + + [Fact] + public void MalformedConnectionsFileIsQuarantinedAndDefaultsComeBackAndSavesWork() + { + var path = ConnectionStore.ConfigFilePath; + File.WriteAllText(path, "{ not valid json"); + var store = new ConnectionStore(); + + var loaded = store.Load(); + Assert.Empty(loaded); + + var quarantined = FindQuarantineFiles(path); + Assert.Single(quarantined); + Assert.Contains("not valid json", File.ReadAllText(quarantined[0])); + + store.Save(new List { new() { ServerName = "svr" } }); + Assert.Single(store.Load()); + + File.Delete(quarantined[0]); + } + + [Fact] + public void MissingConnectionsFileIsDefaultsAndSavesWork() + { + var path = ConnectionStore.ConfigFilePath; + if (File.Exists(path)) + File.Delete(path); + var store = new ConnectionStore(); + + Assert.Empty(store.Load()); + + store.Save(new List { new() { ServerName = "svr" } }); + Assert.Single(store.Load()); + } + + [Fact] + public void UnreadableConnectionsFileRefusesToSaveUntilALaterReadSucceeds() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), + "Unix permissions don't reliably block a same-user read the way FileShare.None does on Windows."); + + var path = ConnectionStore.ConfigFilePath; + var original = """[{"ServerName":"kept-server"}]"""; + File.WriteAllText(path, original); + var store = new ConnectionStore(); + + using (new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None)) + { + Assert.Empty(store.Load()); // couldn't read the real list — defaults + + // ConnectionStore.Save throws rather than swallowing, so its callers (the connection + // dialog) hear about it instead of silently losing the saved server. + var ex = Assert.Throws( + () => store.Save(new List { new() { ServerName = "guess" } })); + Assert.Contains(path, ex.Message); + } + + Assert.Equal(original, File.ReadAllText(path)); + + // The lock is gone — a later read succeeds, the block clears, and saves resume. + var reloaded = store.Load(); + Assert.Single(reloaded); + Assert.Equal("kept-server", reloaded[0].ServerName); + + store.Save(new List { new() { ServerName = "kept-server" }, new() { ServerName = "new-server" } }); + Assert.Equal(2, store.Load().Count); + } + + // ── SettingsFile ───────────────────────────────────────────────────── + + [Fact] + public void MalformedSettingsFileIsQuarantinedAndDefaultsComeBackAndUpdatesWork() + { + var path = SettingsFile.Path; + File.WriteAllText(path, "{ not valid json"); + + var obj = SettingsFile.Read(); + Assert.Empty(obj); + + var quarantined = FindQuarantineFiles(path); + Assert.Single(quarantined); + Assert.Contains("not valid json", File.ReadAllText(quarantined[0])); + + SettingsFile.Update(o => o["mcp_port"] = 5555); + Assert.Equal(5555, SettingsFile.Read()["mcp_port"]!.GetValue()); + + File.Delete(quarantined[0]); + } + + /// + /// succeeds on a top-level array — it just isn't a + /// , which the old + /// as JsonObject ?? new JsonObject() cast silently treated as "empty file" rather than + /// as the wrong-shape document it actually is. + /// + [Fact] + public void ArrayShapedSettingsFileIsTreatedAsMalformedNotEmpty() + { + var path = SettingsFile.Path; + File.WriteAllText(path, "[1,2,3]"); + + var obj = SettingsFile.Read(); + Assert.Empty(obj); + + var quarantined = FindQuarantineFiles(path); + Assert.Single(quarantined); + Assert.Equal("[1,2,3]", File.ReadAllText(quarantined[0])); + + File.Delete(quarantined[0]); + } + + [Fact] + public void MissingSettingsFileIsDefaultsAndUpdatesWork() + { + var path = SettingsFile.Path; + if (File.Exists(path)) + File.Delete(path); + + Assert.Empty(SettingsFile.Read()); + + SettingsFile.Update(o => o["mcp_port"] = 6111); + Assert.Equal(6111, SettingsFile.Read()["mcp_port"]!.GetValue()); + } + + [Fact] + public void UnreadableSettingsFileRefusesToUpdateUntilALaterReadSucceeds() + { + Assert.SkipUnless(OperatingSystem.IsWindows(), + "Unix permissions don't reliably block a same-user read the way FileShare.None does on Windows."); + + var path = SettingsFile.Path; + const string original = """{"mcp_port": 4321}"""; + File.WriteAllText(path, original); + + using (new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None)) + { + Assert.Empty(SettingsFile.Read()); // couldn't read the real value — defaults + + // Update is read-modify-write; refusing here is what stops a blocked read from + // dropping every OTHER key (proxy settings included) on the next Update. + var ex = Assert.Throws(() => SettingsFile.Update(o => o["mcp_port"] = 9999)); + Assert.Contains(path, ex.Message); + } + + Assert.Equal(original, File.ReadAllText(path)); + + // The lock is gone — a later read succeeds, the block clears, and updates resume. + Assert.Equal(4321, SettingsFile.Read()["mcp_port"]!.GetValue()); + + SettingsFile.Update(o => o["mcp_port"] = 4322); + Assert.Equal(4322, SettingsFile.Read()["mcp_port"]!.GetValue()); + } + + // ── helpers ────────────────────────────────────────────────────────── + + private static string[] FindQuarantineFiles(string originalPath) => + Directory.GetFiles( + Path.GetDirectoryName(originalPath)!, + Path.GetFileName(originalPath) + ".bad-*"); +} diff --git a/tests/PlanViewer.Core.Tests/SettingsIntegrationsTests.cs b/tests/PlanViewer.Core.Tests/SettingsIntegrationsTests.cs index 552a8130..6dcb66c2 100644 --- a/tests/PlanViewer.Core.Tests/SettingsIntegrationsTests.cs +++ b/tests/PlanViewer.Core.Tests/SettingsIntegrationsTests.cs @@ -32,6 +32,13 @@ namespace PlanViewer.Core.Tests; /// delete a credential, and before it was redirected the only thing standing between a test and /// the developer's real stored password was nobody having written that test yet. /// +/// +/// Shares a collection with , which locks the very files +/// these tests read and write (settings.json, appsettings.json) to simulate an unreadable file. +/// xunit runs different collections in parallel by default; sharing one here is what stops that +/// lock from being held while a test here happens to touch the same path on another thread. +/// +[Collection("SettingsFileStore serial")] public class SettingsIntegrationsTests { [Fact] From 4cc0c2ba593383afedef2ed083580595ed910695 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:15:31 -0400 Subject: [PATCH 28/85] MCP host: report a failed start instead of showing Running StartMcpServer fired McpHostService.StartAsync and set the menu to "Running" in the same breath, before Kestrel had bound anything. A failure to bind -- most often another process already on the configured port -- only reached Debug.WriteLine, so the menu kept saying Running while the server was actually down. - McpHostService.ExecuteAsync splits the old combined RunAsync into StartAsync followed by WaitForShutdownAsync, so a bind failure is observable on its own. A new Started task resolves to null on success, a short reason on failure (naming the port when it is taken), or cancelled if the host stopped before either happened. - DescribeStartFailure walks the exception chain for Kestrel's AddressInUseException or a bare SocketException(AddressAlreadyInUse), and falls back to the exception's own message for anything else. - MainWindow sets "MCP Server: Starting (port N)" immediately, then resolves it to Running or Failed once Started completes, posted to the UI thread. A closed window is left alone. BuildMcpStatusHeader holds the three header strings so they can be tested without a real port or window, the same way DecideClose already covers CloseAction. - McpHostServiceTests: an occupied port resolves Started to its reason; a free port resolves it to null. - McpStatusHeaderTests: pins the three header strings directly. - Updated the now-stale comment on RunningServer.WaitUntilListeningAsync, which used to say a start failure reached only the debugger. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- src/PlanViewer.App/MainWindow.axaml.cs | 67 +++++++++++++- src/PlanViewer.App/Mcp/McpHostService.cs | 64 +++++++++++++- .../McpHostServiceTests.cs | 88 ++++++++++++++++--- .../McpStatusHeaderTests.cs | 36 ++++++++ 4 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 tests/PlanViewer.Core.Tests/McpStatusHeaderTests.cs diff --git a/src/PlanViewer.App/MainWindow.axaml.cs b/src/PlanViewer.App/MainWindow.axaml.cs index 796cbd49..5125442b 100644 --- a/src/PlanViewer.App/MainWindow.axaml.cs +++ b/src/PlanViewer.App/MainWindow.axaml.cs @@ -334,10 +334,75 @@ 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})", + McpServerStatus.Failed => $"MCP Server: Failed ({failureReason})", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null) + }; + protected override async void OnClosed(EventArgs e) { try diff --git a/src/PlanViewer.App/Mcp/McpHostService.cs b/src/PlanViewer.App/Mcp/McpHostService.cs index 6dff68ab..b758af9c 100644 --- a/src/PlanViewer.App/Mcp/McpHostService.cs +++ b/src/PlanViewer.App/Mcp/McpHostService.cs @@ -1,8 +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; @@ -28,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, @@ -40,6 +51,14 @@ 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 @@ -126,16 +145,57 @@ 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 + /// exception's own message, whatever that turns out to be. + /// + private 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"; + } } + + return ex.Message; } internal static bool IsLoopbackAddress(IPAddress? address) diff --git a/tests/PlanViewer.Core.Tests/McpHostServiceTests.cs b/tests/PlanViewer.Core.Tests/McpHostServiceTests.cs index b8532b0f..64868d2d 100644 --- a/tests/PlanViewer.Core.Tests/McpHostServiceTests.cs +++ b/tests/PlanViewer.Core.Tests/McpHostServiceTests.cs @@ -80,6 +80,78 @@ public void AConnectionWithNoAddressIsRefused() Assert.False(McpHostService.IsLoopbackAddress(null)); } + /// + /// A taken port is the one way this server fails to start that a developer will actually + /// hit — Settings > Integrations lets two windows agree on the same port, or a leftover + /// process from a previous run never released it. Started has to name the reason, and the + /// reason has to name the port, or the menu item is just as unhelpful as Debug.WriteLine was. + /// + [Fact] + public async Task AnOccupiedPortResolvesStartedToTheReason() + { + var cancellationToken = TestContext.Current.CancellationToken; + var port = FreePort(); + + // Held open for the whole test, unlike FreePort()'s own listener, which is stopped + // before it returns — this one has to still be bound when McpHostService tries. + var occupier = new TcpListener(IPAddress.Loopback, port); + occupier.Start(); + + var service = new McpHostService( + new PlanSessionManager(), new ConnectionStore(), new InMemoryCredentialService(), port); + try + { + await service.StartAsync(cancellationToken); + + var reason = await service.Started; + + Assert.Equal($"port {port} is in use", reason); + } + finally + { + occupier.Stop(); + await service.StopAsync(CancellationToken.None); + service.Dispose(); + } + } + + /// + /// The success half of the same contract: Started resolves to null, not just "eventually + /// stops throwing". already covers the + /// server actually working once up; this one is only about the signal that it got there. + /// + [Fact] + public async Task AFreePortResolvesStartedToNull() + { + var cancellationToken = TestContext.Current.CancellationToken; + var port = FreePort(); + + var service = new McpHostService( + new PlanSessionManager(), new ConnectionStore(), new InMemoryCredentialService(), port); + try + { + await service.StartAsync(cancellationToken); + + var reason = await service.Started; + + Assert.Null(reason); + } + finally + { + await service.StopAsync(CancellationToken.None); + service.Dispose(); + } + } + + private static int FreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + /// /// One McpHostService on a free loopback port, stopped on dispose. It never saves the /// connection store and keeps credentials in memory, so no test touches the user's files. @@ -123,18 +195,12 @@ public async ValueTask DisposeAsync() _service.Dispose(); } - private static int FreePort() - { - var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - var port = ((IPEndPoint)listener.LocalEndpoint).Port; - listener.Stop(); - return port; - } - /// - /// The service starts its host in the background and reports a failure to start only - /// to the debugger, so the port is the one sign that it came up. + /// Accepting a real connection is a stronger signal than + /// resolving to null — Started only means Kestrel's own StartAsync returned, this means + /// the port actually answers. Kept as the readiness check for the tests that go on to + /// call tools over it; and + /// cover Started itself. /// private static async Task WaitUntilListeningAsync(int port, CancellationToken cancellationToken) { diff --git a/tests/PlanViewer.Core.Tests/McpStatusHeaderTests.cs b/tests/PlanViewer.Core.Tests/McpStatusHeaderTests.cs new file mode 100644 index 00000000..7aabc620 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/McpStatusHeaderTests.cs @@ -0,0 +1,36 @@ +using PlanViewer.App; + +namespace PlanViewer.Core.Tests; + +/// +/// MainWindow.BuildMcpStatusHeader is the one place the exact wording of the MCP status menu +/// item is decided, split out — the same way CloseAction and DecideClose are — so the three +/// outcomes can be pinned without a window, a port, or a real McpHostService behind them. See +/// McpHostServiceTests for what actually decides Running versus Failed. +/// +public class McpStatusHeaderTests +{ + [Fact] + public void StartingNamesThePort() + { + Assert.Equal( + "MCP Server: Starting (port 5150)", + MainWindow.BuildMcpStatusHeader(MainWindow.McpServerStatus.Starting, 5150)); + } + + [Fact] + public void RunningNamesThePort() + { + Assert.Equal( + "MCP Server: Running (port 5150)", + MainWindow.BuildMcpStatusHeader(MainWindow.McpServerStatus.Running, 5150)); + } + + [Fact] + public void FailedNamesTheReasonInsteadOfThePort() + { + Assert.Equal( + "MCP Server: Failed (port 5150 is in use)", + MainWindow.BuildMcpStatusHeader(MainWindow.McpServerStatus.Failed, 5150, "port 5150 is in use")); + } +} From c7df5353f2cb7c002497e9cd5aa25b95c41af17e Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:15:38 -0400 Subject: [PATCH 29/85] Fix drill-down database not switching the session's toolbar (E1) The Overview's DrillDownRequested handler set _selectedDatabase and _connectionString directly, so Execute and Get Actual Plan ran in the drilled database while DatabaseBox kept showing the old one. Route the drill-down through the picker instead (TrySelectDrilledDatabase), so Database_SelectionChanged does the real work exactly as a user's own pick would; a database missing from the picker leaves the session alone and still opens the Query Store tab. Also: a plan opened from a Query Store grid now remembers that grid's own database (independent of the toolbar's), and Get Actual Plan runs such a plan back against it via a new ResolveExecutionTarget helper, instead of silently switching it to the toolbar's database. The confirmation dialog now names the database the query will run in. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Controls/PlanViewerControl.axaml.cs | 14 ++ .../QuerySessionControl.Connection.cs | 25 +++ .../Controls/QuerySessionControl.Execution.cs | 38 ++++- .../Controls/QuerySessionControl.Plans.cs | 12 +- .../QuerySessionControl.QueryStore.cs | 8 +- .../Controls/QuerySessionControl.Views.cs | 13 +- .../DrillDownDatabaseTests.cs | 154 ++++++++++++++++++ .../SessionTestHarness.cs | 8 + 8 files changed, 265 insertions(+), 7 deletions(-) create mode 100644 tests/PlanViewer.Core.Tests/DrillDownDatabaseTests.cs 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.Connection.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs index 4512eba6..f99f81e3 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs @@ -112,6 +112,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; diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs index 2d5c7fea..2b193040 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs @@ -266,6 +266,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,10 +311,17 @@ 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; @@ -374,7 +402,7 @@ 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); @@ -390,6 +418,12 @@ 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. + if (loadingTab.Content is PlanViewerControl recapturedViewer) + recapturedViewer.SourceDatabase = viewer.SourceDatabase; } catch (OperationCanceledException) { diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs index 2fbd2fd5..37e30839 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs @@ -33,9 +33,18 @@ 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); + + /// + /// 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. Passed straight through to + /// ; see its doc comment for why (E1). + /// + private bool AddPlanTab(string planXml, string queryText, bool estimated, string? labelOverride, + string? sourceDatabase, out string? failure) { failure = null; _planCounter++; @@ -46,6 +55,7 @@ private bool AddPlanTab(string planXml, string queryText, bool estimated, string viewer.HostedInSession = true; viewer.Metadata = _serverMetadata; viewer.ConnectionString = _connectionString; + viewer.SourceDatabase = sourceDatabase; viewer.SetConnectionServices(_credentialService, _connectionStore); if (_serverConnection != null) viewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase); diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs b/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs index 2ce5c549..63ef749e 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.QueryStore.cs @@ -244,12 +244,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); diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Views.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Views.cs index 10db6db7..3834a2a1 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Views.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Views.cs @@ -191,9 +191,16 @@ private QueryStoreOverviewControl BuildOverviewView() 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/tests/PlanViewer.Core.Tests/DrillDownDatabaseTests.cs b/tests/PlanViewer.Core.Tests/DrillDownDatabaseTests.cs new file mode 100644 index 00000000..0a634f36 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/DrillDownDatabaseTests.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia.Controls; +using PlanViewer.App.Controls; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Tests; + +/// +/// The Overview's drill-down is meant to switch the session to the database it drilled into — +/// not just open a Query Store tab against it while the toolbar keeps showing whatever database +/// the session was on before (E1). is +/// the seam: it does exactly what a user picking that database in DatabaseBox would do, and +/// nothing more when the drilled database is not one the picker knows about. +/// +/// A plan opened from a Query Store grid carries the same gap one step further: the grid +/// has its own database picker, independent of the toolbar's, so Get Actual Plan on such a plan +/// used to run it wherever the toolbar happened to be pointed rather than where the plan came +/// from. is pinned directly here, per +/// the ruling, rather than through a real execution. +/// +public class DrillDownDatabaseTests +{ + [Fact] + public void DrillingDownSwitchesTheSessionToTheDrilledDatabase() + { + HeadlessUi.Run(() => + { + var (window, session) = SessionHarness.NewSession(); + try + { + SessionHarness.PretendConnected(session, database: "master"); + var databaseBox = session.FindControl("DatabaseBox")!; + databaseBox.ItemsSource = new List { "master", "Sales", "Ops" }; + databaseBox.SelectedItem = "master"; + + var found = session.TrySelectDrilledDatabase("Sales"); + + Assert.True(found); + Assert.Equal("Sales", databaseBox.SelectedItem); + Assert.Equal("Sales", SessionHarness.SelectedDatabase(session)); + Assert.Contains("Sales", SessionHarness.ConnectionString(session) ?? ""); + } + finally + { + ChromeTestCleanup.PutAway(window); + } + }); + } + + /// + /// A database created after connect is not in the picker's list. Nothing names it, so + /// nothing is selected — the session stays exactly as it was; only the Query Store tab the + /// caller opens afterward (QuerySessionControl.Views.cs) actually reaches the new database. + /// + [Fact] + public void DrillingDownToADatabaseMissingFromThePickerLeavesTheSessionAlone() + { + HeadlessUi.Run(() => + { + var (window, session) = SessionHarness.NewSession(); + try + { + SessionHarness.PretendConnected(session, database: "master"); + var databaseBox = session.FindControl("DatabaseBox")!; + databaseBox.ItemsSource = new List { "master", "Sales" }; + databaseBox.SelectedItem = "master"; + + var databaseBefore = SessionHarness.SelectedDatabase(session); + var connectionStringBefore = SessionHarness.ConnectionString(session); + + var found = session.TrySelectDrilledDatabase("CreatedAfterConnect"); + + Assert.False(found); + Assert.Equal("master", databaseBox.SelectedItem); + Assert.Equal(databaseBefore, SessionHarness.SelectedDatabase(session)); + Assert.Equal(connectionStringBefore, SessionHarness.ConnectionString(session)); + } + finally + { + ChromeTestCleanup.PutAway(window); + } + }); + } + + /// + /// A plan pulled out of a Query Store grid resolves Get Actual Plan to that grid's own + /// database, even though the toolbar is pointed somewhere else — and a plan with no grid of + /// its own (every other plan tab: executed, pasted, opened from History) resolves to the + /// toolbar's, unchanged. + /// + [Fact] + public void GetActualPlanResolvesAGridPlanToItsGridsDatabaseAndOthersToTheToolbars() + { + HeadlessUi.Run(() => + { + var (window, session) = SessionHarness.NewSession(); + try + { + SessionHarness.PretendConnected(session, database: "master"); + + var grid = new QueryStoreGridControl( + new ServerConnection { ServerName = "tcp:127.0.0.1,1", DisplayName = "unit test" }, + new NoCredentials(), + initialDatabase: "Sales", + databases: new List { "master", "Sales" }); + + session.OnQueryStorePlansSelected(grid, new List + { + new() + { + QueryId = 1, + PlanId = 1, + QueryText = "select 1;", + PlanXml = SessionHarness.SamplePlanXml(), + }, + }); + + var gridViewer = (PlanViewerControl)SessionHarness.Documents(session).Single().Content!; + Assert.Equal("Sales", gridViewer.SourceDatabase); + + var (gridDatabase, gridConnectionString) = session.ResolveExecutionTarget(gridViewer); + Assert.Equal("Sales", gridDatabase); + Assert.Contains("Sales", gridConnectionString ?? ""); + + // A plan with no grid of its own — the same path History and a pasted plan use. + // The grid plan opened above is still in the strip, so take the newest tab + // (OpenPlanDocuments always selects the one it just added) rather than the only one. + var pastedTab = SessionHarness.OpenPlanDocuments(session).Last(); + var pastedViewer = (PlanViewerControl)pastedTab.Content!; + Assert.Null(pastedViewer.SourceDatabase); + + var (pastedDatabase, pastedConnectionString) = session.ResolveExecutionTarget(pastedViewer); + Assert.Equal("master", pastedDatabase); + Assert.Contains("master", pastedConnectionString ?? ""); + } + finally + { + ChromeTestCleanup.PutAway(window); + } + }); + } + + /// Windows-auth credentials so the grid's connection-string build asks for nothing. + private sealed class NoCredentials : ICredentialService + { + public bool SaveCredential(string serverId, string username, string password) => false; + public (string Username, string Password)? GetCredential(string serverId) => null; + public bool DeleteCredential(string serverId) => false; + public bool CredentialExists(string serverId) => false; + public bool UpdateCredential(string serverId, string username, string password) => false; + } +} diff --git a/tests/PlanViewer.Core.Tests/SessionTestHarness.cs b/tests/PlanViewer.Core.Tests/SessionTestHarness.cs index 74c31697..3f058d7e 100644 --- a/tests/PlanViewer.Core.Tests/SessionTestHarness.cs +++ b/tests/PlanViewer.Core.Tests/SessionTestHarness.cs @@ -178,6 +178,14 @@ whatever SQL Server the machine running this happens to have. Same address the o internal static QueryStoreOverviewControl? OverviewView(QuerySessionControl session) => (QueryStoreOverviewControl?)GetField(session, "_overviewView"); + /// The database the toolbar's picker last settled on, off the session's own field. + internal static string? SelectedDatabase(QuerySessionControl session) => + (string?)GetField(session, "_selectedDatabase"); + + /// The connection string the toolbar last built, off the session's own field. + internal static string? ConnectionString(QuerySessionControl session) => + (string?)GetField(session, "_connectionString"); + /// /// Calls the session's private InvalidateOverviewView, which is the last line of the /// connect block and the only thing in the app that runs it. From 429d4afbfae0ddc8b77b630686abb62eee4be9ef Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:17:00 -0400 Subject: [PATCH 30/85] Fix overlapping Query Store enabled checks on the grid's database picker (E6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QsDatabase_SelectionChanged had no way to tell a superseded check from the newest one, so picking a second database before the first one's CheckEnabledAsync landed let whichever finished last write _database/ _connectionString, even for a database the user had already clicked past. Cancel the previous check's token when a new pick starts, thread it into CheckEnabledAsync, and bail out silently if this call turns out to be the one that got cancelled — same shape as the Overview's load-generation guard. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../Controls/QueryStoreGridControl.axaml.cs | 31 ++++++- .../QueryStoreDatabaseCheckRaceTests.cs | 85 +++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 tests/PlanViewer.Core.Tests/QueryStoreDatabaseCheckRaceTests.cs diff --git a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs index 7ac00c06..4160a4d3 100644 --- a/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QueryStoreGridControl.axaml.cs @@ -27,6 +27,12 @@ public partial class QueryStoreGridControl : UserControl 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(); @@ -156,13 +162,30 @@ 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. */ + _databaseCheckCts?.Cancel(); + _databaseCheckCts?.Dispose(); + 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 +195,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 diff --git a/tests/PlanViewer.Core.Tests/QueryStoreDatabaseCheckRaceTests.cs b/tests/PlanViewer.Core.Tests/QueryStoreDatabaseCheckRaceTests.cs new file mode 100644 index 00000000..54d04c7c --- /dev/null +++ b/tests/PlanViewer.Core.Tests/QueryStoreDatabaseCheckRaceTests.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using Avalonia.Controls; +using PlanViewer.App.Controls; +using PlanViewer.Core.Interfaces; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Tests; + +/// +/// The grid's database picker checks Query Store is enabled before switching (QsDatabase_ +/// SelectionChanged), and used to let whichever check happened to finish last apply — even for a +/// database the user had already clicked past. Picking twice in a row, before the first check can +/// land, has to leave only the newest pick's check able to run to completion (E6). +/// +/// Proven the same way the Overview's own load-generation race is (SessionViewLifecycleTests. +/// AskingForTheOverviewAgainReloadsTheSameControl): reflect out the CancellationTokenSource each +/// pick is running on and check the older one was cancelled by the newer, rather than waiting on +/// the pretend server's connection attempts to actually resolve. +/// +public class QueryStoreDatabaseCheckRaceTests +{ + [Fact] + public void PickingASecondDatabaseCancelsTheFirstOnesCheck() + { + HeadlessUi.Run(() => + { + var grid = new QueryStoreGridControl( + new ServerConnection { ServerName = "tcp:127.0.0.1,1", DisplayName = "unit test" }, + new NoCredentials(), + initialDatabase: "master", + databases: new List { "master", "A", "B" }); + + var databaseBox = grid.FindControl("QsDatabaseBox")!; + + databaseBox.SelectedItem = "A"; + var firstCheck = (CancellationTokenSource?)GetField(grid, "_databaseCheckCts"); + Assert.NotNull(firstCheck); + Assert.False(firstCheck!.IsCancellationRequested); + + databaseBox.SelectedItem = "B"; + var secondCheck = (CancellationTokenSource?)GetField(grid, "_databaseCheckCts"); + + Assert.NotSame(firstCheck, secondCheck); + Assert.True(firstCheck.IsCancellationRequested, + "picking a second database left the first one's Query Store check running alongside it"); + }); + } + + /// + /// Picking the database the grid is already on is a no-op — nothing to race, and nothing + /// should touch the check field at all. + /// + [Fact] + public void ReselectingTheCurrentDatabaseStartsNoCheck() + { + HeadlessUi.Run(() => + { + var grid = new QueryStoreGridControl( + new ServerConnection { ServerName = "tcp:127.0.0.1,1", DisplayName = "unit test" }, + new NoCredentials(), + initialDatabase: "master", + databases: new List { "master", "A" }); + + var databaseBox = grid.FindControl("QsDatabaseBox")!; + databaseBox.SelectedItem = "master"; + + Assert.Null(GetField(grid, "_databaseCheckCts")); + }); + } + + private static object? GetField(object target, string name) => + target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(target); + + /// Windows-auth credentials so the grid's connection-string build asks for nothing. + private sealed class NoCredentials : ICredentialService + { + public bool SaveCredential(string serverId, string username, string password) => false; + public (string Username, string Password)? GetCredential(string serverId) => null; + public bool DeleteCredential(string serverId) => false; + public bool CredentialExists(string serverId) => false; + public bool UpdateCredential(string serverId, string username, string password) => false; + } +} From 573bc09c00f2e93a0731c9bc1b3376474128ff14 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:19:10 -0400 Subject: [PATCH 31/85] Fix overlapping database metadata fetches on the toolbar's picker (E7) FetchDatabaseMetadataAsync had no way to tell a superseded fetch from the newest one, so picking a second database before the first fetch landed let whichever finished last overwrite _serverMetadata.Database, even for a database already clicked past. Same fix as E6: cancel the previous fetch's token when a new pick starts, thread it into ServerMetadataService.FetchDatabaseMetadataAsync, and bail out silently if this call turns out to be the one that got cancelled. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01G625JBNh45iTR1hpT4CxNR --- .../QuerySessionControl.Connection.cs | 22 ++++++- .../Controls/QuerySessionControl.axaml.cs | 6 ++ .../DatabaseMetadataFetchRaceTests.cs | 62 +++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 tests/PlanViewer.Core.Tests/DatabaseMetadataFetchRaceTests.cs diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs index f99f81e3..a14dbce8 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Connection.cs @@ -171,10 +171,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). */ + _databaseMetadataCts?.Cancel(); + _databaseMetadataCts?.Dispose(); + 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.axaml.cs b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs index 03d21a8b..5e2dd732 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs @@ -107,6 +107,12 @@ public void MarkClean() private int _planCounter; private CancellationTokenSource? _executionCts; 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/tests/PlanViewer.Core.Tests/DatabaseMetadataFetchRaceTests.cs b/tests/PlanViewer.Core.Tests/DatabaseMetadataFetchRaceTests.cs new file mode 100644 index 00000000..5ad6ef51 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/DatabaseMetadataFetchRaceTests.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using Avalonia.Controls; +using PlanViewer.App.Controls; +using PlanViewer.Core.Models; + +namespace PlanViewer.Core.Tests; + +/// +/// Database_SelectionChanged calls FetchDatabaseMetadataAsync on every pick, and that fetch used +/// to let whichever call finished last write _serverMetadata.Database — even for a database the +/// user had already clicked past. Picking twice in a row, before the first fetch can land, has to +/// leave only the newest pick's fetch able to write (E7). Same shape, and same proof technique, as +/// — reflect out the CancellationTokenSource each +/// pick is running on rather than waiting for the pretend server's connection attempts to resolve. +/// +public class DatabaseMetadataFetchRaceTests +{ + [Fact] + public void PickingASecondDatabaseCancelsTheFirstMetadataFetch() + { + HeadlessUi.Run(() => + { + var (window, session) = SessionHarness.NewSession(); + try + { + SessionHarness.PretendConnected(session, database: "master"); + + // FetchDatabaseMetadataAsync's null guard returns before ever reaching the CTS + // swap without this — a fresh, empty ServerMetadata is enough to get past it. + SetField(session, "_serverMetadata", new ServerMetadata()); + + var databaseBox = session.FindControl("DatabaseBox")!; + databaseBox.ItemsSource = new List { "master", "A", "B" }; + databaseBox.SelectedItem = "master"; + + databaseBox.SelectedItem = "A"; + var firstFetch = (CancellationTokenSource?)GetField(session, "_databaseMetadataCts"); + Assert.NotNull(firstFetch); + Assert.False(firstFetch!.IsCancellationRequested); + + databaseBox.SelectedItem = "B"; + var secondFetch = (CancellationTokenSource?)GetField(session, "_databaseMetadataCts"); + + Assert.NotSame(firstFetch, secondFetch); + Assert.True(firstFetch.IsCancellationRequested, + "picking a second database left the first one's metadata fetch running alongside it"); + } + finally + { + ChromeTestCleanup.PutAway(window); + } + }); + } + + private static object? GetField(object target, string name) => + target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(target); + + private static void SetField(object target, string name, object? value) => + target.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.SetValue(target, value); +} From 863a10049a4b61a371ea0135bb252e5e22a78ac2 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:31:50 -0400 Subject: [PATCH 32/85] Report a refused settings save in the dialogs, and keep the server-filter panel state The connection dialog and the Settings window both let the IOException that ConnectionStore.Save and SettingsFile.Update now throw escape their click handlers, which crashes the app. Both now catch IOException and UnauthorizedAccessException, show the message, and stay open. The connection dialog reports into its existing StatusText through a small TrySaveConnection helper, which can be tested without a live SQL connection. The Settings window had no error display, so it gets a text block in the button bar. ServerFilterExpander_StateChanged cloned the cached AppSettings, changed the clone and saved it. AppSettingsService cached the clone, but MainWindow kept its own older _appSettings, and MainWindow's next save (opening a plan, closing a tab) wrote the older object back over the clone, reverting the panel's expanded state. It now changes the shared cached instance in place, as every other AppSettings save in MainWindow does. The new test drives the real grid control and MainWindow, and fails against the old code. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza --- .../QueryStoreGridControl.ServerFilters.cs | 13 +- .../Dialogs/ConnectionDialog.axaml.cs | 26 +++- .../Dialogs/SettingsWindow.axaml | 28 ++-- .../Dialogs/SettingsWindow.axaml.cs | 21 ++- tests/PlanViewer.Core.Tests/HeadlessUi.cs | 11 +- .../SaveFailureDisplayTests.cs | 130 ++++++++++++++++++ .../ServerFilterPanelPersistenceTests.cs | 57 ++++++++ 7 files changed, 267 insertions(+), 19 deletions(-) create mode 100644 tests/PlanViewer.Core.Tests/SaveFailureDisplayTests.cs create mode 100644 tests/PlanViewer.Core.Tests/ServerFilterPanelPersistenceTests.cs 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/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/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 @@ - - public static void Update(Action mutate) { - var obj = Read(); - if (_updateBlocked) + var obj = Read(out var unreadable); + if (unreadable) throw SettingsFileStore.UnreadableSaveRefused(Path); mutate(obj); From b99cfdd6a9cb9acf9db83f5e3833484dbb8fcbbc Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:00:10 -0400 Subject: [PATCH 47/85] Add storage limit, daily upload budget and one client key to PlanShare Sharing is refused with 507 once the database uses 10 GB, counted as (page_count - freelist_count) * page_size. /api/event skips the insert in that state and still answers 200, so analytics never show an error. Each client key can store 100 MB of plan data per UTC day. The budget is in memory like the rate limiters and CleanupService sweeps it. Every per-client limit (share, analytics, read, budget) now uses one key: an IPv4 address, an IPv4-mapped IPv6 address as its IPv4 address, and any other IPv6 address as its /64. The visitor hash still uses the full address. /api/share and /api/event answer 400 instead of 500 for a JSON root that is not an object and for fields of the wrong type. /api/event refuses a path over 512 characters. DELETE /api/plans/{id} takes the token in the X-Delete-Token header and still accepts ?token=. Refusals carry an "error" text in a JSON body. The new classes are internal with InternalsVisibleTo, and the test project now references server/PlanShare so CI builds and tests it. The ci.yml path filter includes server/PlanShare. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza --- .github/workflows/ci.yml | 4 +- server/PlanShare/ClientKey.cs | 33 +++++ server/PlanShare/PlanShare.csproj | 5 + server/PlanShare/Program.cs | 127 ++++++++++++++---- server/PlanShare/StorageCheck.cs | 41 ++++++ server/PlanShare/UploadBudget.cs | 88 ++++++++++++ .../PlanShareClientKeyTests.cs | 65 +++++++++ .../PlanShareStorageCheckTests.cs | 95 +++++++++++++ .../PlanShareUploadBudgetTests.cs | 127 ++++++++++++++++++ .../PlanViewer.Core.Tests.csproj | 3 + 10 files changed, 561 insertions(+), 27 deletions(-) create mode 100644 server/PlanShare/ClientKey.cs create mode 100644 server/PlanShare/StorageCheck.cs create mode 100644 server/PlanShare/UploadBudget.cs create mode 100644 tests/PlanViewer.Core.Tests/PlanShareClientKeyTests.cs create mode 100644 tests/PlanViewer.Core.Tests/PlanShareStorageCheckTests.cs create mode 100644 tests/PlanViewer.Core.Tests/PlanShareUploadBudgetTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2b044f6..1936c1ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,13 +31,15 @@ jobs: # 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' - 'tests/**' - 'PlanViewer.sln' diff --git a/server/PlanShare/ClientKey.cs b/server/PlanShare/ClientKey.cs new file mode 100644 index 00000000..4457b777 --- /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 cannot hold two keys. Any other IPv6 +/// address becomes its /64 prefix: a single subscriber is normally handed a whole /64, so a +/// limit per full IPv6 address would never be reached by a caller who rotates through it. +/// +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..92abd428 100644 --- a/server/PlanShare/PlanShare.csproj +++ b/server/PlanShare/PlanShare.csproj @@ -18,4 +18,9 @@ + + + + + diff --git a/server/PlanShare/Program.cs b/server/PlanShare/Program.cs index a144de34..1ddf429b 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,24 @@ 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. Stopping at 10 GB of used database pages leaves room for the OS, +// logs, the SQLite journal and a VACUUM, and a full store turns shares away instead of filling +// the disk. The budget caps one client key's stored plan data per UTC day; it is per key and not +// global, so one heavy uploader cannot use up the space for everyone else in a single day. +// PlanShare:MaxDatabaseBytes and PlanShare:DailyUploadBytes override the limits (tests and local runs). +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 +122,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 +141,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 +154,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 +164,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 +214,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 +238,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 +250,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 +291,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 +470,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 +508,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 +524,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 +576,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/tests/PlanViewer.Core.Tests/PlanShareClientKeyTests.cs b/tests/PlanViewer.Core.Tests/PlanShareClientKeyTests.cs new file mode 100644 index 00000000..64466bf2 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanShareClientKeyTests.cs @@ -0,0 +1,65 @@ +using System.Net; +using PlanShare; + +namespace PlanViewer.Core.Tests; + +/// +/// Every per-client limit on the share server counts under one key. An IPv4 address is its own key, +/// an IPv4-mapped IPv6 address is the IPv4 address it wraps, and any other IPv6 address is its /64. +/// +public class PlanShareClientKeyTests +{ + [Fact] + public void IPv4Address_IsItsOwnKey() + { + Assert.Equal("203.0.113.7", ClientKey.From(IPAddress.Parse("203.0.113.7"))); + } + + [Fact] + public void IPv4MappedIPv6Address_HasTheSameKeyAsTheIPv4Address() + { + var mapped = ClientKey.From(IPAddress.Parse("::ffff:203.0.113.7")); + + Assert.Equal("203.0.113.7", mapped); + Assert.Equal(ClientKey.From(IPAddress.Parse("203.0.113.7")), mapped); + } + + [Fact] + public void IPv6Addresses_InOneSlash64_ShareAKey() + { + var first = ClientKey.From(IPAddress.Parse("2001:db8:1:2::1")); + var second = ClientKey.From(IPAddress.Parse("2001:db8:1:2:ffff:eeee:dddd:cccc")); + + Assert.Equal(first, second); + Assert.Equal("2001:db8:1:2::/64", first); + } + + [Fact] + public void IPv6Addresses_InDifferentSlash64s_HaveDifferentKeys() + { + // Differ only in the last bit of the 64-bit prefix + var first = ClientKey.From(IPAddress.Parse("2001:db8:1:2::1")); + var second = ClientKey.From(IPAddress.Parse("2001:db8:1:3::1")); + + Assert.NotEqual(first, second); + } + + [Fact] + public void IPv6ZoneId_IsNotPartOfTheKey() + { + Assert.Equal("fe80::/64", ClientKey.From(IPAddress.Parse("fe80::1%3"))); + } + + [Fact] + public void IPv6Key_EndsInSlash64_SoItCannotEqualAnIPv4Key() + { + Assert.EndsWith("/64", ClientKey.From(IPAddress.Parse("2001:db8::1"))); + Assert.DoesNotContain('/', ClientKey.From(IPAddress.Parse("203.0.113.7"))); + } + + [Fact] + public void NoAddress_GivesTheUnknownKey() + { + Assert.Equal("unknown", ClientKey.From(null)); + } +} diff --git a/tests/PlanViewer.Core.Tests/PlanShareStorageCheckTests.cs b/tests/PlanViewer.Core.Tests/PlanShareStorageCheckTests.cs new file mode 100644 index 00000000..d37b7418 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanShareStorageCheckTests.cs @@ -0,0 +1,95 @@ +using Microsoft.Data.Sqlite; +using PlanShare; + +namespace PlanViewer.Core.Tests; + +/// +/// The share server stops taking uploads when the plan database reaches its size limit. Size is the +/// pages in use, (page_count - freelist_count) * page_size, so these tests run against a real SQLite +/// file with a small limit instead of a mock. +/// +public class PlanShareStorageCheckTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), "planshare-storage-" + Guid.NewGuid().ToString("N")); + private readonly string _dbPath; + private readonly string _connectionString; + + public PlanShareStorageCheckTests() + { + Directory.CreateDirectory(_directory); + _dbPath = Path.Combine(_directory, "plans.db"); + // No pooling, so the file is closed and can be deleted as soon as a command finishes + _connectionString = $"Data Source={_dbPath};Pooling=False"; + Execute("CREATE TABLE plans (id INTEGER PRIMARY KEY, data BLOB NOT NULL);"); + } + + public void Dispose() + { + try { Directory.Delete(_directory, recursive: true); } + catch (IOException) { } + } + + [Fact] + public void UsedBytes_IsTheFileLength_WhenNoPagesAreFree() + { + AddPlans(rows: 30, bytesPerRow: 8000); + + var used = new StorageCheck(_connectionString, long.MaxValue).UsedBytes(); + + Assert.True(used > 30 * 8000); + Assert.Equal(new FileInfo(_dbPath).Length, used); + } + + [Fact] + public void UsedBytes_LeavesOutFreePages_AfterRowsAreDeleted() + { + AddPlans(rows: 30, bytesPerRow: 8000); + var length = new FileInfo(_dbPath).Length; + + Execute("DELETE FROM plans;"); + + var used = new StorageCheck(_connectionString, long.MaxValue).UsedBytes(); + Assert.Equal(length, new FileInfo(_dbPath).Length); + Assert.True(used < length / 4, $"used {used} of {length}"); + } + + [Fact] + public void IsFull_IsTrueAtTheLimit_AndFalseJustBelowIt() + { + AddPlans(rows: 30, bytesPerRow: 8000); + var used = new StorageCheck(_connectionString, long.MaxValue).UsedBytes(); + + Assert.True(new StorageCheck(_connectionString, used).IsFull()); + Assert.True(new StorageCheck(_connectionString, used - 1).IsFull()); + Assert.False(new StorageCheck(_connectionString, used + 1).IsFull()); + } + + [Fact] + public void IsFull_GoesBackToFalse_WhenDeletedPlansFreeTheirPagesAndTheFileDoesNotShrink() + { + AddPlans(rows: 30, bytesPerRow: 8000); + var limit = 100 * 1024; + var check = new StorageCheck(_connectionString, limit); + Assert.True(check.IsFull()); + + Execute("DELETE FROM plans;"); + + Assert.True(new FileInfo(_dbPath).Length >= limit); + Assert.False(check.IsFull()); + } + + private void AddPlans(int rows, int bytesPerRow) + { + Execute($"WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < {rows}) " + + $"INSERT INTO plans (data) SELECT zeroblob({bytesPerRow}) FROM n;"); + } + + private void Execute(string sql) + { + using var conn = new SqliteConnection(_connectionString); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } +} diff --git a/tests/PlanViewer.Core.Tests/PlanShareUploadBudgetTests.cs b/tests/PlanViewer.Core.Tests/PlanShareUploadBudgetTests.cs new file mode 100644 index 00000000..48bed972 --- /dev/null +++ b/tests/PlanViewer.Core.Tests/PlanShareUploadBudgetTests.cs @@ -0,0 +1,127 @@ +using PlanShare; + +namespace PlanViewer.Core.Tests; + +/// +/// The share server lets one client key store a fixed number of bytes per UTC day. The clock is +/// injected so the day change can be tested without waiting for it. +/// +public class PlanShareUploadBudgetTests +{ + private const long Limit = 100; + + private sealed class FakeClock : TimeProvider + { + public DateTimeOffset Now { get; set; } = new(2026, 9, 28, 12, 0, 0, TimeSpan.Zero); + + public override DateTimeOffset GetUtcNow() => Now; + } + + [Fact] + public void Charges_UpToTheLimit_AreAllowed() + { + var budget = new UploadBudget(Limit, new FakeClock()); + + Assert.True(budget.TryCharge("client", 60)); + Assert.True(budget.TryCharge("client", 40)); + } + + [Fact] + public void ChargePastTheLimit_IsRefused() + { + var budget = new UploadBudget(Limit, new FakeClock()); + Assert.True(budget.TryCharge("client", 100)); + + Assert.False(budget.TryCharge("client", 1)); + } + + [Fact] + public void SingleChargeLargerThanTheLimit_IsRefused() + { + var budget = new UploadBudget(Limit, new FakeClock()); + + Assert.False(budget.TryCharge("client", Limit + 1)); + } + + [Fact] + public void RefusedCharge_AddsNothingToTheTotal() + { + var budget = new UploadBudget(Limit, new FakeClock()); + Assert.True(budget.TryCharge("client", 90)); + + Assert.False(budget.TryCharge("client", 20)); + Assert.True(budget.TryCharge("client", 10)); + } + + [Fact] + public void EachClientKey_HasItsOwnBudget() + { + var budget = new UploadBudget(Limit, new FakeClock()); + Assert.True(budget.TryCharge("first", 100)); + + Assert.False(budget.TryCharge("first", 1)); + Assert.True(budget.TryCharge("second", 100)); + } + + [Fact] + public void Budget_ResetsAtTheStartOfTheNextUtcDay() + { + var clock = new FakeClock { Now = new DateTimeOffset(2026, 9, 28, 23, 59, 59, TimeSpan.Zero) }; + var budget = new UploadBudget(Limit, clock); + Assert.True(budget.TryCharge("client", 100)); + Assert.False(budget.TryCharge("client", 1)); + + clock.Now = new DateTimeOffset(2026, 9, 29, 0, 0, 0, TimeSpan.Zero); + + Assert.True(budget.TryCharge("client", 100)); + Assert.False(budget.TryCharge("client", 1)); + } + + [Fact] + public void Day_IsTheUtcDay_NotTheLocalDay() + { + // Both times are on 28 September at UTC-5, but 18:30 is 23:30 UTC and 19:30 is 00:30 UTC + // on the next day, so the second charge lands in a new budget. + var clock = new FakeClock { Now = new DateTimeOffset(2026, 9, 28, 18, 30, 0, TimeSpan.FromHours(-5)) }; + var budget = new UploadBudget(Limit, clock); + Assert.True(budget.TryCharge("client", 100)); + Assert.False(budget.TryCharge("client", 1)); + + clock.Now = new DateTimeOffset(2026, 9, 28, 19, 30, 0, TimeSpan.FromHours(-5)); + + Assert.True(budget.TryCharge("client", 100)); + } + + [Fact] + public void Sweep_RemovesKeysFromEarlierDays_AndKeepsTodaysCharges() + { + var clock = new FakeClock(); + var budget = new UploadBudget(Limit, clock); + Assert.True(budget.TryCharge("old-1", 10)); + Assert.True(budget.TryCharge("old-2", 10)); + + clock.Now = clock.Now.AddDays(1); + Assert.True(budget.TryCharge("current", 60)); + + Assert.Equal(2, budget.Sweep()); + Assert.Equal(0, budget.Sweep()); + Assert.False(budget.TryCharge("current", 50)); + Assert.True(budget.TryCharge("current", 40)); + } + + [Fact] + public void ConcurrentCharges_NeverPassTheLimit() + { + const int limit = 1000; + var budget = new UploadBudget(limit, new FakeClock()); + var allowed = 0; + + Parallel.For(0, 4000, _ => + { + if (budget.TryCharge("client", 1)) + Interlocked.Increment(ref allowed); + }); + + Assert.Equal(limit, allowed); + } +} diff --git a/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj index ea2973a7..487b64e8 100644 --- a/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj +++ b/tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj @@ -44,6 +44,9 @@ + + From 525e05c856f045370234adcd641a8b9438605837 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:06:15 -0400 Subject: [PATCH 48/85] Point a Query Store plan's tab at the plan's own database A plan opened from a Query Store grid kept the toolbar's connection string and status label, so Show Indexes and Show Table Definition looked in the toolbar's database. ConnectViewer now sets the source database, connection string and label together, for AddPlanTab and for the tab a Get Actual Plan capture lands in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza --- .../Controls/QuerySessionControl.Execution.cs | 12 +++---- .../Controls/QuerySessionControl.Plans.cs | 28 +++++++++++---- .../DrillDownDatabaseTests.cs | 34 +++++++++++++++++++ 3 files changed, 59 insertions(+), 15 deletions(-) diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs index 2b193040..174faf2f 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Execution.cs @@ -208,16 +208,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; @@ -417,13 +415,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. - if (loadingTab.Content is PlanViewerControl recapturedViewer) - recapturedViewer.SourceDatabase = viewer.SourceDatabase; + ShowCapturedPlan(loadingTab, actualPlanXml, tabLabel, queryText, viewer.SourceDatabase); } catch (OperationCanceledException) { diff --git a/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs b/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs index 37e30839..807afca9 100644 --- a/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs +++ b/src/PlanViewer.App/Controls/QuerySessionControl.Plans.cs @@ -38,10 +38,28 @@ private bool AddPlanTab(string planXml, string queryText, bool estimated, string 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. Passed straight through to - /// ; see its doc comment for why (E1). + /// 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) @@ -54,11 +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.SourceDatabase = sourceDatabase; - viewer.SetConnectionServices(_credentialService, _connectionStore); - if (_serverConnection != null) - viewer.SetConnectionStatus(_serverConnection.ServerName, _selectedDatabase); + ConnectViewer(viewer, sourceDatabase); viewer.OpenInEditorRequested += OnOpenInEditorRequested; if (!viewer.LoadPlan(planXml, label, queryText)) diff --git a/tests/PlanViewer.Core.Tests/DrillDownDatabaseTests.cs b/tests/PlanViewer.Core.Tests/DrillDownDatabaseTests.cs index 0a634f36..77a9fa71 100644 --- a/tests/PlanViewer.Core.Tests/DrillDownDatabaseTests.cs +++ b/tests/PlanViewer.Core.Tests/DrillDownDatabaseTests.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using Avalonia.Controls; +using Microsoft.Data.SqlClient; using PlanViewer.App.Controls; using PlanViewer.Core.Interfaces; using PlanViewer.Core.Models; @@ -119,6 +120,8 @@ public void GetActualPlanResolvesAGridPlanToItsGridsDatabaseAndOthersToTheToolba var gridViewer = (PlanViewerControl)SessionHarness.Documents(session).Single().Content!; Assert.Equal("Sales", gridViewer.SourceDatabase); + // Its schema lookups (Show Indexes, Show Table Definition) run on this. + Assert.Equal("Sales", new SqlConnectionStringBuilder(gridViewer.ConnectionString).InitialCatalog); var (gridDatabase, gridConnectionString) = session.ResolveExecutionTarget(gridViewer); Assert.Equal("Sales", gridDatabase); @@ -130,6 +133,7 @@ public void GetActualPlanResolvesAGridPlanToItsGridsDatabaseAndOthersToTheToolba var pastedTab = SessionHarness.OpenPlanDocuments(session).Last(); var pastedViewer = (PlanViewerControl)pastedTab.Content!; Assert.Null(pastedViewer.SourceDatabase); + Assert.Equal("master", new SqlConnectionStringBuilder(pastedViewer.ConnectionString).InitialCatalog); var (pastedDatabase, pastedConnectionString) = session.ResolveExecutionTarget(pastedViewer); Assert.Equal("master", pastedDatabase); @@ -142,6 +146,36 @@ public void GetActualPlanResolvesAGridPlanToItsGridsDatabaseAndOthersToTheToolba }); } + /// + /// Get Actual Plan on a grid plan lands through ShowCapturedPlan, in a tab of its own. That + /// tab keeps the grid's database, so a second Get Actual Plan and its schema lookups stay on it. + /// + [Fact] + public void ACapturedPlanKeepsItsSourceDatabase() + { + HeadlessUi.Run(() => + { + var (window, session) = SessionHarness.NewSession(); + try + { + SessionHarness.PretendConnected(session, database: "master"); + + var tab = new TabItem { Header = "Plan 1", Content = new Grid() }; + session.ShowCapturedPlan(tab, SessionHarness.SamplePlanXml(), "Plan 1", "select 1;", + sourceDatabase: "Sales"); + + var viewer = (PlanViewerControl)tab.Content!; + Assert.Equal("Sales", viewer.SourceDatabase); + Assert.Equal("Sales", new SqlConnectionStringBuilder(viewer.ConnectionString).InitialCatalog); + Assert.Equal("Sales", session.ResolveExecutionTarget(viewer).Database); + } + finally + { + ChromeTestCleanup.PutAway(window); + } + }); + } + /// Windows-auth credentials so the grid's connection-string build asks for nothing. private sealed class NoCredentials : ICredentialService { From 13ec8a120ca138aae0ba43899ffd0010494fd98c Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:07:58 -0400 Subject: [PATCH 49/85] Show share errors, send the delete token in a header, fix the dashboard The web client reads the "error" text from a failed share (507, 429, 400) and shows it. A reply without that text, such as an HTML page from the proxy, keeps the generic message with the status code. DeleteAsync sends the token in X-Delete-Token instead of ?token=, which the proxy writes to its access log. The share dialog lists what the upload holds: the plan file name, the query text, operator details and warnings, compiled and runtime parameter values, missing index suggestions with database, schema and table names, and the full text report. The payload is unchanged. dashboard.html called history.replaceState, but a local array named history shadows window.history, so the call threw and left #token= in the address bar. It now calls window.history.replaceState. Tests: the endpoints run through WebApplicationFactory with the database in a temp folder and the limits passed as PlanShare:* settings, and the web share service runs against a stub HTTP handler. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_019n3G844aTidqrD6A6iMgza --- server/PlanShare/dashboard.html | 4 +- src/PlanViewer.Web/Pages/Index.razor | 11 +- .../Services/PlanShareService.cs | 32 +- src/PlanViewer.Web/wwwroot/css/app.css | 7 + tests/PlanViewer.Core.Tests/PlanShareApp.cs | 84 ++++ .../PlanShareEndpointTests.cs | 383 ++++++++++++++++++ .../PlanShareServiceTests.cs | 112 +++++ .../PlanViewer.Core.Tests.csproj | 7 + 8 files changed, 636 insertions(+), 4 deletions(-) create mode 100644 tests/PlanViewer.Core.Tests/PlanShareApp.cs create mode 100644 tests/PlanViewer.Core.Tests/PlanShareEndpointTests.cs create mode 100644 tests/PlanViewer.Core.Tests/PlanShareServiceTests.cs 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/PlanViewer.Web/Pages/Index.razor b/src/PlanViewer.Web/Pages/Index.razor index e9b6b09d..eec1ec21 100644 --- a/src/PlanViewer.Web/Pages/Index.razor +++ b/src/PlanViewer.Web/Pages/Index.razor @@ -76,7 +76,16 @@ else