Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 5 additions & 1 deletion src/PlanViewer.App/Controls/QuerySessionControl.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,15 @@ public QuerySessionControl(ICredentialService credentialService, ConnectionStore
QueryEditor.TextArea.Focus();
};

// Dispose TextMate when detached (e.g. tab switch) to release renderers/transformers
// Dispose TextMate when detached (e.g. tab switch) to release renderers/transformers.
// Also cancel any in-flight status-clear dispatch so it doesn't fire on a dead control.
DetachedFromVisualTree += (_, _) =>
{
_textMateInstallation?.Dispose();
_textMateInstallation = null;
_statusClearCts?.Cancel();
_statusClearCts?.Dispose();
_statusClearCts = null;
};

// Focus the editor when the Editor tab is selected; toggle plan-dependent buttons
Expand Down
10 changes: 10 additions & 0 deletions src/PlanViewer.App/Dialogs/QueryStoreHistoryWindow.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,16 @@ public static string MapOrderByToMetricTag(string orderBy)
: "AvgCpuMs";
}

protected override void OnClosed(EventArgs e)
{
// Cancel any in-flight history fetch so the SqlConnection doesn't sit open on the
// server after the dialog is dismissed.
_fetchCts?.Cancel();
_fetchCts?.Dispose();
_fetchCts = null;
base.OnClosed(e);
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing, not introduced here, but worth flagging while you're in this file: LoadHistoryAsync (lines 191–239) catches OperationCanceledException but the success path still touches StatusText, HistoryDataGrid.ItemsSource, UpdateChart(), and PopulateLegendPanel() without checking ct.IsCancellationRequested. If the fetch returns normally in the tiny window between OnClosed firing and the await resuming, those UI writes happen on a closed window. Consider a ct.ThrowIfCancellationRequested() after the await (or before the UI writes) so the post-await path routes through the OperationCanceledException catch.


Generated by Claude Code


private async System.Threading.Tasks.Task LoadHistoryAsync()
{
_fetchCts?.Cancel();
Expand Down
9 changes: 6 additions & 3 deletions src/PlanViewer.Core/Services/KeychainCredentialService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,14 @@ private static (int ExitCode, string Output) RunSecurity(params string[] args)
using var process = Process.Start(psi);
if (process == null) return (-1, string.Empty);

// Read both streams concurrently. Doing stderr synchronously while stdout is
// async can deadlock if stderr fills its pipe buffer before the process exits
// (reproduces on `security dump-keychain` with large keychains).
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderr = process.StandardError.ReadToEnd();
var stderrTask = process.StandardError.ReadToEndAsync();
process.WaitForExit();
var stdout = stdoutTask.Result;
Task.WaitAll(stdoutTask, stderrTask);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Task.WaitAll(stdoutTask, stderrTask) after process.WaitForExit() is redundant — once the process has exited the pipes are closed and both ReadToEndAsync tasks will already be complete. Not a bug (it does guarantee exceptions surface rather than being lost on the task), just noting it. The Microsoft docs actually recommend the opposite order: start async reads, then WaitForExit (or WaitForExitAsync) — because WaitForExit without draining can itself block indefinitely in some shell scenarios. Current ordering works because both reads are async, but flipping to await Task.WhenAll(stdoutTask, stderrTask); process.WaitForExit(); would be the textbook pattern.


Generated by Claude Code

return (process.ExitCode, stdout + stderr);
return (process.ExitCode, stdoutTask.Result + stderrTask.Result);
}
}
23 changes: 20 additions & 3 deletions src/PlanViewer.Core/Services/ReproScriptBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ namespace PlanViewer.Core.Services;
/// </summary>
public static class ReproScriptBuilder
{
/// <summary>
/// Valid T-SQL isolation level names (uppercase) that may appear in a SET TRANSACTION
/// ISOLATION LEVEL statement. Used to gate interpolation so arbitrary upstream strings
/// can't land in the generated script.
/// </summary>
private static readonly HashSet<string> IsolationLevels = new(StringComparer.Ordinal)
{
"READ UNCOMMITTED",
"READ COMMITTED",
"REPEATABLE READ",
"SNAPSHOT",
"SERIALIZABLE",
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whitelist is correct per T-SQL spec — these are exactly the five values SET TRANSACTION ISOLATION LEVEL accepts. READ COMMITTED SNAPSHOT is deliberately absent and should be, since RCSI is a database-level option (ALTER DATABASE … SET READ_COMMITTED_SNAPSHOT ON) and not a transaction isolation level keyword. 👍

No tests in tests/PlanViewer.Core.Tests/ cover ReproScriptBuilder whitelist or ] escaping behavior — given this is now the security-hardening layer for a code-gen path, a couple of xunit cases pinning the two behaviors (valid level passes through, hostile string is silently dropped; cool]stuffcool]]stuff) would be cheap insurance against regressions.


Generated by Claude Code

};

/// <summary>
/// Builds a complete reproduction script from available query data.
/// </summary>
Expand Down Expand Up @@ -94,10 +108,11 @@ public static string BuildReproScript(
sb.AppendLine("*/");
sb.AppendLine();

/* USE database (skip for Azure SQL DB — USE is invalid there) */
/* USE database (skip for Azure SQL DB — USE is invalid there).
Double any ']' in the identifier so names like 'cool]stuff' still parse. */
if (!string.IsNullOrEmpty(databaseName) && !isAzureSqlDb)
{
sb.AppendLine($"USE [{databaseName}];");
sb.AppendLine($"USE [{databaseName.Replace("]", "]]")}];");
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escape is correct for the bracket-quoted identifier form. If anywhere else in this builder emits the database name without [...] brackets (e.g. as a plain token or inside a single-quoted string for sp_executesql), the ]→]] doubling alone isn't the right escape for those forms. A quick grep for other databaseName usages in this file confirms whether this is the only emission site.


Generated by Claude Code

sb.AppendLine();
}

Expand All @@ -116,7 +131,9 @@ public static string BuildReproScript(

if (!string.IsNullOrEmpty(isolationLevel))
{
sb.AppendLine($"SET TRANSACTION ISOLATION LEVEL {isolationLevel.ToUpperInvariant()};");
var upper = isolationLevel.ToUpperInvariant();
if (IsolationLevels.Contains(upper))
sb.AppendLine($"SET TRANSACTION ISOLATION LEVEL {upper};");
}
sb.AppendLine("SET NOCOUNT ON;");
sb.AppendLine();
Expand Down
3 changes: 2 additions & 1 deletion src/PlanViewer.Core/Services/ShowPlanParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1832,6 +1832,7 @@ private static double ParseDouble(string? value)
private static long ParseLong(string? value)
{
if (string.IsNullOrEmpty(value)) return 0;
return long.TryParse(value, out var result) ? result : 0;
return long.TryParse(value, System.Globalization.NumberStyles.Integer,
System.Globalization.CultureInfo.InvariantCulture, out var result) ? result : 0;
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good alignment with ParseDouble. PR body notes the overflow case (99999…) still returns 0 — that's fine for ShowPlan's invariant-formatted payloads, just worth noting that silent-zero-on-overflow could mask a malformed plan. Not worth changing in this PR.


Generated by Claude Code

}
}
Loading