Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6decaf8
Skip the AI review job on Dependabot PRs
erikdarlingdata Aug 3, 2026
b4bcb76
Merge pull request #423 from erikdarlingdata/fix/skip-review-on-depen…
erikdarlingdata Aug 3, 2026
8001b90
deps: Bump ModelContextProtocol and ModelContextProtocol.AspNetCore
dependabot[bot] Aug 3, 2026
f491a3e
deps: Bump Microsoft.SqlServer.TransactSql.ScriptDom and SQLitePCLRaw…
dependabot[bot] Aug 3, 2026
0aa07cb
Merge pull request #420 from erikdarlingdata/dependabot/nuget/server/…
erikdarlingdata Aug 3, 2026
a33fe34
Merge pull request #422 from erikdarlingdata/dependabot/nuget/src/Pla…
erikdarlingdata Aug 3, 2026
9aa6030
deps: Bump the patch-and-minor group with 6 updates
dependabot[bot] Aug 10, 2026
2141498
Merge pull request #424 from erikdarlingdata/dependabot/nuget/src/Pla…
erikdarlingdata Aug 10, 2026
956b778
Give the WAM broker a parent window handle so Entra MFA works (#425)
erikdarlingdata Aug 11, 2026
5f84116
Watchdog the test-host hang from outside the process
erikdarlingdata Aug 11, 2026
897d241
Merge pull request #427 from erikdarlingdata/test-hang-watchdog
erikdarlingdata Aug 11, 2026
28be5ed
Address review: marshal handle lookup to the UI thread; pin the CLI r…
erikdarlingdata Aug 11, 2026
e5e40b8
Merge pull request #426 from erikdarlingdata/entra-wam-window-handle-425
erikdarlingdata Aug 12, 2026
f6f53c7
Stop Robot Advice from crashing the app on a deep plan (#430) (#431)
erikdarlingdata Aug 16, 2026
344e3fe
deps: Bump the patch-and-minor group with 11 updates
dependabot[bot] Aug 17, 2026
1c17b06
Only call a conversion non-SARGable when it converts the column (#436…
erikdarlingdata Aug 20, 2026
718b2ca
Give querystore the depth ceiling that analyze got (#430) (#438)
erikdarlingdata Aug 20, 2026
60eb0bf
Say which warnings are SQL Server's and which are ours (#436) (#439)
erikdarlingdata Aug 20, 2026
a83c6e0
Merge pull request #432 from erikdarlingdata/dependabot/nuget/server/…
erikdarlingdata Aug 21, 2026
557f910
Stop corrupting memory resolving an open file handle on macOS (#441) …
erikdarlingdata Aug 21, 2026
42045a1
Move the test suite to Microsoft.Testing.Platform for xunit.v3 4.0.0 …
erikdarlingdata Aug 21, 2026
3e8ff03
Bump version to 1.20.0 (#444)
erikdarlingdata Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,4 @@ jobs:
run: dotnet build PlanViewer.sln -c Release --no-restore

- name: Run tests
run: dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-build --verbosity normal
run: dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-build --verbosity normal -- --hangdump --hangdump-timeout 5m --hangdump-type none
8 changes: 8 additions & 0 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,17 @@ jobs:
# were each already reviewed on their own PR, so re-reviewing the whole
# release adds nothing — and the diff is large enough that it reliably
# exhausts the turn budget and fails, putting a red X on the release.
#
# Also skip Dependabot PRs. They fail twice over instead of getting a
# review: the action rejects non-human actors outright ("Workflow initiated
# by non-human actor: dependabot"), and a dependabot-triggered run draws
# from the DEPENDABOT secret store, not the Actions one, so the OAuth token
# is empty anyway. A version bump has nothing an AI review would catch that
# build-and-test doesn't; skipped is neutral, not a red X on every bump.
if: |
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]' &&
!(github.event.pull_request.head.ref == 'dev' && github.event.pull_request.base.ref == 'main')
runs-on: ubuntu-latest

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ jobs:
dotnet restore tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj

- name: Run tests
run: dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --verbosity normal
run: dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --verbosity normal -- --hangdump --hangdump-timeout 5m --hangdump-type none

- name: Publish App (all platforms)
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
run: |
dotnet restore
dotnet build -c Release
dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-build --verbosity normal
dotnet test tests/PlanViewer.Core.Tests/PlanViewer.Core.Tests.csproj -c Release --no-build --verbosity normal -- --hangdump --hangdump-timeout 5m --hangdump-type none

- name: Publish App (all platforms)
run: |
Expand Down
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ authors:
website: "https://erikdarling.com"
repository-code: "https://github.com/erikdarlingdata/PerformanceStudio"
license: MIT
version: "1.19.1"
date-released: "2026-07-29"
version: "1.20.0"
date-released: "2026-08-21"
keywords:
- sql-server
- execution-plan
Expand Down
20 changes: 20 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,26 @@ PerformanceStudio/
- No unnecessary abstractions — keep it simple and direct
- Tests use real `.sqlplan` XML fixtures, not mocks

## Calling native code

**Never call a variadic C function through a plain `DllImport`.** `fcntl`, `open`, `ioctl` and the
`printf` family all take `...`, and on Apple arm64 variadic arguments are passed on the *stack* while
a fixed-signature P/Invoke passes them in *registers*. The callee reads a stack slot you never wrote.

This does not throw, and it does not return an error. In #441 `fcntl(F_GETPATH)` returned 0 for
success and wrote up to 1KB through whatever pointer happened to be in that slot — an arbitrary write
into the process on every call — while the buffer we passed came back empty. It made `dotnet test`
unrunnable on macOS for months, sometimes as a GC livelock and sometimes as a deadlock, and CI never
saw any of it because Linux and Windows take different branches.

Use a non-variadic equivalent instead (`proc_pidfdinfo` in place of `fcntl(F_GETPATH)`, for example).
`__arglist` is not an escape hatch — it throws `Vararg calling convention not supported` on this
target.

When you do P/Invoke a struct-returning native call, derive the offsets in a comment from the system
header rather than leaving magic numbers, and check the returned size against what you expected. A
layout change should fail loudly, not hand back plausible-looking wrong bytes.

## Adding Analysis Rules

Rules live in `PlanAnalyzer.cs`. Each rule:
Expand Down
5 changes: 5 additions & 0 deletions global.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}
4 changes: 2 additions & 2 deletions server/PlanShare/PlanShare.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.10" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
<!-- Direct ref lifts the transitive SQLitePCLRaw above 2.1.11, which bundles a
SQLite vulnerable to CVE-2025-6965 (GHSA-2m69-gcr7-jv3q). Microsoft.Data.Sqlite
still floors at 2.1.11, so this ref is what keeps the native library patched;
drop it only once that floor moves past 2.1.11 on its own. 3.0.4 bundles
SQLite 3.53.3, and Microsoft.Data.Sqlite 10.0.10 works against the 3.x
native layer (full CRUD verified). -->
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.5" />
</ItemGroup>

</Project>
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
Tests and server/ projects are outside src/ and are unaffected.
-->
<PropertyGroup>
<Version>1.19.1</Version>
<Version>1.20.0</Version>
<Authors>Erik Darling</Authors>
<Company>Darling Data LLC</Company>
<Product>Performance Studio</Product>
Expand Down
53 changes: 53 additions & 0 deletions src/PlanViewer.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@
using Avalonia.Markup.Xaml;
using Avalonia.Platform;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using System.Linq;
using System.Threading.Tasks;
using PlanViewer.App.Services;
using PlanViewer.Core.Services;

namespace PlanViewer.App;

Expand All @@ -30,6 +32,13 @@ public override void OnFrameworkInitializationCompleted()
desktop.MainWindow = new MainWindow();
}

// Entra MFA needs a parent window handle for the WAM broker, or the prompt never
// appears and the connection fails with 0xwindow_handle_required (issue #425).
// Registered once here because SqlAuthenticationProvider is process-wide, so this
// covers every connection Studio opens without touching each call site. No-op off
// Windows, where interactive auth uses the browser and needs no handle.
EntraInteractiveAuth.Register(ActiveWindowHandle);

if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
var iconPath = System.IO.Path.Combine(AppContext.BaseDirectory, "EDD.icns");
Expand All @@ -50,6 +59,50 @@ public override void OnFrameworkInitializationCompleted()
base.OnFrameworkInitializationCompleted();
}

/// <summary>
/// The window that should own an Entra MFA prompt, resolved at the moment MSAL asks (issue #425).
///
/// <para>Prefers whichever window is currently active over the main window, because a connection is
/// usually triggered from the connection dialog — parenting the account picker to the main window behind
/// it would let the picker appear behind the dialog the user is looking at. Falls back to the main window,
/// then to <see cref="IntPtr.Zero"/>, which MSAL treats the same as no handle: the prompt fails rather
/// than the app crashing, which is the right way round for an auth path.</para>
///
/// <para>Resolved per call rather than captured once: a window's platform handle is not valid until the
/// window has been sourced, so a handle read at startup can be zero even though a real window exists a
/// moment later.</para>
///
/// <para>Marshaled to the UI thread: MSAL invokes this from whatever thread SqlClient's token acquisition
/// happens to run on, and <c>desktop.Windows</c> is a UI-thread-owned collection that the UI thread can
/// mutate (a dialog opening or closing) mid-enumeration. Blocking on <see cref="Dispatcher.UIThread"/> is
/// safe here because no connection path blocks the UI thread on auth — every open in the app is async. If
/// the dispatcher can't deliver anyway (shutdown timing), Zero degrades to MSAL's normal no-handle
/// failure instead of throwing from inside the auth callback.</para>
/// </summary>
private static IntPtr ActiveWindowHandle()
{
try
{
return Dispatcher.UIThread.CheckAccess()
? ActiveWindowHandleOnUIThread()
: Dispatcher.UIThread.Invoke(ActiveWindowHandleOnUIThread);
}
catch
{
return IntPtr.Zero;
}
}

private static IntPtr ActiveWindowHandleOnUIThread()
{
if (Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
return IntPtr.Zero;

var window = desktop.Windows.FirstOrDefault(w => w.IsActive) ?? desktop.MainWindow;

return window?.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
}

/// <summary>
/// Handles macOS file-open activations (<see cref="ActivationKind.File"/>). The
/// opened plan paths arrive here via the activation event rather than argv, so we
Expand Down
10 changes: 6 additions & 4 deletions src/PlanViewer.App/Controls/PlanViewerControl.Properties.cs
Original file line number Diff line number Diff line change
Expand Up @@ -818,9 +818,10 @@ private void ShowPropertiesPanel(PlanNode node)
: w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
var legacyTag = w.IsLegacy ? " [legacy]" : "";
var sourceTag = WarningSourceTag(w);
var planWarnHeader = w.MaxBenefitPercent.HasValue
? $"\u26A0 {w.WarningType}{legacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
: $"\u26A0 {w.WarningType}{legacyTag}";
? $"\u26A0 {w.WarningType}{sourceTag}{legacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
: $"\u26A0 {w.WarningType}{sourceTag}{legacyTag}";
warnPanel.Children.Add(new TextBlock
{
Text = planWarnHeader,
Expand Down Expand Up @@ -901,9 +902,10 @@ private void ShowPropertiesPanel(PlanNode node)
: w.Severity == PlanWarningSeverity.Warning ? "#FFB347" : "#6BB5FF";
var warnPanel = new StackPanel { Margin = new Thickness(10, 2, 10, 2) };
var nodeLegacyTag = w.IsLegacy ? " [legacy]" : "";
var nodeSourceTag = WarningSourceTag(w);
var nodeWarnHeader = w.MaxBenefitPercent.HasValue
? $"\u26A0 {w.WarningType}{nodeLegacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
: $"\u26A0 {w.WarningType}{nodeLegacyTag}";
? $"\u26A0 {w.WarningType}{nodeSourceTag}{nodeLegacyTag} \u2014 up to {FormatBenefitPercent(w.MaxBenefitPercent.Value)}% benefit"
: $"\u26A0 {w.WarningType}{nodeSourceTag}{nodeLegacyTag}";
warnPanel.Children.Add(new TextBlock
{
Text = nodeWarnHeader,
Expand Down
8 changes: 8 additions & 0 deletions src/PlanViewer.App/Controls/PlanViewerControl.Rendering.cs
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,14 @@ private static string FormatBytes(double bytes)
private static string FormatBenefitPercent(double pct) =>
pct >= 100 ? $"{pct:N0}" : $"{pct:N1}";

/// <summary>
/// #436: marks the warnings SQL Server itself wrote into the plan, so they are not read as one of
/// our inferences. Only the engine's are tagged — they are the minority, and a badge on every
/// warning would carry no information.
/// </summary>
private static string WarningSourceTag(PlanWarning warning) =>
warning.Source == PlanWarningSource.SqlServer ? " [SQL Server]" : "";

private static bool HasSpillInPlanTree(PlanNode node)
{
foreach (var w in node.Warnings)
Expand Down
16 changes: 15 additions & 1 deletion src/PlanViewer.App/Controls/QuerySessionControl.Advice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,21 @@ private void RobotAdvice_Click(object? sender, RoutedEventArgs e)
var analysis = GetCurrentAnalysis();
if (analysis == null) { SetStatus("No plan to analyze", autoClear: false); return; }

var json = JsonSerializer.Serialize(analysis, new JsonSerializerOptions { WriteIndented = true });
string json;
try
{
json = JsonSerializer.Serialize(analysis, AnalysisJson.Indented);
}
catch (Exception ex) when (ex is JsonException or NotSupportedException)
{
/* #430: Avalonia does not guard click handlers, so anything thrown on this path takes the
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. */
SetStatus($"Could not build robot advice for this plan: {ex.Message}", autoClear: false);
return;
}

ShowAdviceWindow("Advice for Robots", json);
}

Expand Down
15 changes: 14 additions & 1 deletion src/PlanViewer.App/MainWindow.PlanViewer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,20 @@ private DockPanel CreatePlanTabContent(PlanViewerControl viewer)
{
if (viewer.CurrentPlan == null) return;
var analysis = ResultMapper.Map(viewer.CurrentPlan, "file", viewer.Metadata);
var json = JsonSerializer.Serialize(analysis, new JsonSerializerOptions { WriteIndented = true });
string json;
try
{
json = JsonSerializer.Serialize(analysis, AnalysisJson.Indented);
}
catch (Exception ex) when (ex is JsonException or NotSupportedException)
{
/* #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}");
return;
}

ShowAdviceWindow("Advice for Robots", json);
};

Expand Down
10 changes: 9 additions & 1 deletion src/PlanViewer.App/Mcp/McpHelpers.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
using System;
using System.Text.Json;
using PlanViewer.Core.Output;

namespace PlanViewer.App.Mcp;

internal static class McpHelpers
{
public const int MaxTop = 100;

public static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
/* #430: MaxDepth, because several of these tools return an analysis carrying an OperatorTree and the
default ceiling of 64 is roughly 30 nested operators. Unlike the UI buttons an MCP tool failing here
does not crash the process, but it does return an error where the caller expected a plan. */
public static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
MaxDepth = AnalysisJson.MaxDepth,
};

public static string? Truncate(string? value, int maxLength)
{
Expand Down
14 changes: 7 additions & 7 deletions src/PlanViewer.App/PlanViewer.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,22 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Avalonia" Version="11.3.18" />
<PackageReference Include="Avalonia" Version="11.3.20" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="11.4.1" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.13" />
<!-- 5.1.58 is the last Avalonia 11 build — 5.1.59+ requires Avalonia >= 12.0.0.
Bump together with the Avalonia 12 migration (branch upgrade/avalonia-12). -->
<PackageReference Include="ScottPlot.Avalonia" Version="5.1.58" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.18" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.18" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.18" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.20" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.20" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.20" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="11.4.1" />
<PackageReference Include="AvaloniaEdit.TextMate.Grammars" Version="0.10.12.1" />
<PackageReference Include="Meziantou.Framework.Win32.CredentialManager" Version="3.0.1" />
<PackageReference Include="ModelContextProtocol" Version="1.4.1" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.4.1" />
<PackageReference Include="ModelContextProtocol" Version="2.2.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="2.2.0" />
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.4" />
<PackageReference Include="Microsoft.SqlServer.TransactSql.ScriptDom" Version="180.59.2" />
<PackageReference Include="Microsoft.SqlServer.TransactSql.ScriptDom" Version="180.78.1" />
<PackageReference Include="Velopack" Version="1.2.0" />

<!-- Pin SkiaSharp native assets to match SkiaSharp 3.119.0.
Expand Down
14 changes: 5 additions & 9 deletions src/PlanViewer.Cli/Commands/AnalyzeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,12 @@ namespace PlanViewer.Cli.Commands;

public static class AnalyzeCommand
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
/* #430: the depth ceiling and the null handling both live on AnalysisJson now. They used to be
built here, which is how the identical pair in QueryStoreCommand got missed when #430 was
fixed — see AnalysisJson.IndentedWithoutNulls. */
private static readonly JsonSerializerOptions JsonOptions = AnalysisJson.IndentedWithoutNulls;

private static readonly JsonSerializerOptions CompactJsonOptions = new()
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private static readonly JsonSerializerOptions CompactJsonOptions = AnalysisJson.CompactWithoutNulls;

public static Command Create(ICredentialService? credentialService = null)
{
Expand Down
20 changes: 20 additions & 0 deletions src/PlanViewer.Cli/Commands/CliConnectionResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,26 @@ public static ServerConnection BuildServerConnection(
throw new InvalidOperationException("No credentials configured");
}

/* Interactive Entra auth goes through the Windows WAM broker, which needs a parent window handle to
own its account picker (issue #425). A CLI has no window to give it, so the connection would fail
deep inside MSAL with "0xwindow_handle_required" — a message that tells the user nothing about
what to do. Say it here instead, up front, and name the modes that actually work headless.

Deliberately not silently substituting another auth mode: picking a different identity than the
one the operator asked for is worse than refusing. Whether the CLI should grow device-code flow is
the open question on #425, not something to guess at from here. */
if (authType == AuthenticationTypes.EntraMFA && !EntraInteractiveAuth.IsSupported)
{
Console.Error.WriteLine(
"Interactive Microsoft Entra MFA (--auth entra) needs a desktop window for the Windows " +
"account picker, so it cannot run from the CLI.");
Console.Error.WriteLine(
"Use the Studio app for interactive sign-in, or a non-interactive identity here: " +
"--auth sql with a stored credential, or a service principal / managed identity.");
Environment.ExitCode = 1;
throw new InvalidOperationException("Interactive Entra authentication is not available headless");
}

return new ServerConnection
{
Id = server,
Expand Down
Loading
Loading