From 9791b56c595db111f4ed7c8a0de1943e5e38afc6 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Mon, 30 Mar 2026 07:55:12 -0400 Subject: [PATCH] Fix memory leaks in Lite: delta cache, event handlers, chart helpers (#758) - DeltaCalculator: add ClearServer() to free cache when server tab closes - MainWindow: store event handler delegates so they can be unsubscribed on tab close (AlertCountsChanged, ApplyTimeRangeRequested, ManualRefreshRequested) - ChartHoverHelper: add Dispose() to unsubscribe MouseMove/MouseLeave events - ServerTab: add DisposeChartHelpers() to clean up all 27 hover helpers - CloseServerTab now calls all cleanup methods Co-Authored-By: Claude Opus 4.6 (1M context) --- Lite/Controls/ServerTab.xaml.cs | 31 +++++++++++++++++++++++++ Lite/Helpers/ChartHoverHelper.cs | 8 +++++++ Lite/MainWindow.xaml.cs | 31 ++++++++++++++++++------- Lite/Services/DeltaCalculator.cs | 9 +++++++ Lite/Services/RemoteCollectorService.cs | 1 + 5 files changed, 72 insertions(+), 8 deletions(-) diff --git a/Lite/Controls/ServerTab.xaml.cs b/Lite/Controls/ServerTab.xaml.cs index c83a7fcb..f3261a5a 100644 --- a/Lite/Controls/ServerTab.xaml.cs +++ b/Lite/Controls/ServerTab.xaml.cs @@ -5243,6 +5243,37 @@ public void StopRefresh() _refreshTimer.Stop(); } + public void DisposeChartHelpers() + { + _waitStatsHover?.Dispose(); + _perfmonHover?.Dispose(); + _overviewCpuHover?.Dispose(); + _overviewMemoryHover?.Dispose(); + _overviewFileIoHover?.Dispose(); + _overviewWaitStatsHover?.Dispose(); + _cpuHover?.Dispose(); + _memoryHover?.Dispose(); + _tempDbHover?.Dispose(); + _tempDbFileIoHover?.Dispose(); + _fileIoReadHover?.Dispose(); + _fileIoWriteHover?.Dispose(); + _fileIoReadThroughputHover?.Dispose(); + _fileIoWriteThroughputHover?.Dispose(); + _collectorDurationHover?.Dispose(); + _queryDurationTrendHover?.Dispose(); + _procDurationTrendHover?.Dispose(); + _queryStoreDurationTrendHover?.Dispose(); + _executionCountTrendHover?.Dispose(); + _lockWaitTrendHover?.Dispose(); + _blockingTrendHover?.Dispose(); + _deadlockTrendHover?.Dispose(); + _memoryClerksHover?.Dispose(); + _memoryGrantSizingHover?.Dispose(); + _memoryGrantActivityHover?.Dispose(); + _currentWaitsDurationHover?.Dispose(); + _currentWaitsBlockedHover?.Dispose(); + } + /* ========== Column Filtering ========== */ private void InitializeFilterManagers() diff --git a/Lite/Helpers/ChartHoverHelper.cs b/Lite/Helpers/ChartHoverHelper.cs index 4450cfba..794b293b 100644 --- a/Lite/Helpers/ChartHoverHelper.cs +++ b/Lite/Helpers/ChartHoverHelper.cs @@ -56,6 +56,14 @@ public ChartHoverHelper(ScottPlot.WPF.WpfPlot chart, string unit) public string Unit { get => _unit; set => _unit = value; } + public void Dispose() + { + _chart.MouseMove -= OnMouseMove; + _chart.MouseLeave -= OnMouseLeave; + _popup.IsOpen = false; + _scatters.Clear(); + } + public void Clear() => _scatters.Clear(); public void Add(ScottPlot.Plottables.Scatter scatter, string label) => diff --git a/Lite/MainWindow.xaml.cs b/Lite/MainWindow.xaml.cs index cd074e04..0573ada5 100644 --- a/Lite/MainWindow.xaml.cs +++ b/Lite/MainWindow.xaml.cs @@ -36,6 +36,7 @@ public partial class MainWindow : Window private CancellationTokenSource? _backgroundCts; private SystemTrayService? _trayService; private readonly Dictionary _openServerTabs = new(); + private readonly Dictionary AlertCounts, Action ApplyTimeRange, Func ManualRefresh)> _tabEventHandlers = new(); private readonly Dictionary _previousConnectionStates = new(); private readonly Dictionary _previousCollectorErrorStates = new(); private readonly Dictionary _lastCpuAlert = new(); @@ -530,15 +531,13 @@ private async void ConnectToServer(ServerConnection server) Content = serverTab }; - /* Subscribe to alert counts for badge updates */ + /* Subscribe to events — store handlers so we can unsubscribe on tab close */ var serverId = server.Id; - serverTab.AlertCountsChanged += (blockingCount, deadlockCount, latestEventTime) => + Action alertHandler = (blockingCount, deadlockCount, latestEventTime) => { Dispatcher.Invoke(() => UpdateTabBadge(tabHeader, serverId, blockingCount, deadlockCount, latestEventTime)); }; - - /* Subscribe to "Apply to All" time range propagation */ - serverTab.ApplyTimeRangeRequested += (selectedIndex) => + Action timeRangeHandler = (selectedIndex) => { Dispatcher.Invoke(() => { @@ -551,9 +550,7 @@ private async void ConnectToServer(ServerConnection server) } }); }; - - /* Re-collect on-load data (config, trace flags) when refresh button is clicked */ - serverTab.ManualRefreshRequested += async () => + Func refreshHandler = async () => { if (_collectorService != null) { @@ -572,6 +569,11 @@ private async void ConnectToServer(ServerConnection server) } }; + serverTab.AlertCountsChanged += alertHandler; + serverTab.ApplyTimeRangeRequested += timeRangeHandler; + serverTab.ManualRefreshRequested += refreshHandler; + _tabEventHandlers[server.Id] = (alertHandler, timeRangeHandler, refreshHandler); + _openServerTabs[server.Id] = tabItem; ServerTabControl.Items.Add(tabItem); ServerTabControl.SelectedItem = tabItem; @@ -793,7 +795,20 @@ private void CloseServerTab(string serverId) { if (tab.Content is ServerTab serverTab) { + /* Unsubscribe event handlers to prevent memory leaks */ + if (_tabEventHandlers.TryGetValue(serverId, out var handlers)) + { + serverTab.AlertCountsChanged -= handlers.AlertCounts; + serverTab.ApplyTimeRangeRequested -= handlers.ApplyTimeRange; + serverTab.ManualRefreshRequested -= handlers.ManualRefresh; + _tabEventHandlers.Remove(serverId); + } + serverTab.StopRefresh(); + serverTab.DisposeChartHelpers(); + + /* Clear delta cache for this server to free memory */ + _collectorService?.DeltaCalculator?.ClearServer(serverTab.ServerId); } ServerTabControl.Items.Remove(tab); diff --git a/Lite/Services/DeltaCalculator.cs b/Lite/Services/DeltaCalculator.cs index 383f0542..990a507c 100644 --- a/Lite/Services/DeltaCalculator.cs +++ b/Lite/Services/DeltaCalculator.cs @@ -59,6 +59,15 @@ public async Task SeedFromDatabaseAsync(DuckDbInitializer duckDb) } } + /// + /// Removes all cached entries for a server (e.g., when the server tab is closed). + /// Next collection will re-seed from database if needed. + /// + public void ClearServer(int serverId) + { + _cache.TryRemove(serverId, out _); + } + /// /// Calculates the delta between the current value and the previous cached value. /// First-ever sighting (no baseline): returns currentValue so single-execution queries appear. diff --git a/Lite/Services/RemoteCollectorService.cs b/Lite/Services/RemoteCollectorService.cs index be1f9576..aa2918f3 100644 --- a/Lite/Services/RemoteCollectorService.cs +++ b/Lite/Services/RemoteCollectorService.cs @@ -59,6 +59,7 @@ public partial class RemoteCollectorService private readonly ScheduleManager _scheduleManager; private readonly ILogger? _logger; private readonly DeltaCalculator _deltaCalculator; + public DeltaCalculator DeltaCalculator => _deltaCalculator; private static long s_idCounter = DateTime.UtcNow.Ticks; ///