diff --git a/Darling/Darling.Tests/LiteJobHistoryLoadingCapTests.cs b/Darling/Darling.Tests/LiteJobHistoryLoadingCapTests.cs
new file mode 100644
index 000000000..cb9e4254c
--- /dev/null
+++ b/Darling/Darling.Tests/LiteJobHistoryLoadingCapTests.cs
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2026 Erik Darling, Darling Data LLC
+ *
+ * This file is part of the SQL Server Performance Monitor.
+ *
+ * Licensed under the MIT License. See LICENSE file in the project root for full license information.
+ */
+
+using System;
+using System.Text.RegularExpressions;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #4478's Lite half: Lite's Job History tab gets the same loading state and "showing the newest 2,000"
+/// cap label that #4481 gave the Viewer's tab, through the shared
+/// . Lite's UI is WPF (PresentationFramework) and
+/// cannot be instantiated here, so this pins the SOURCE for the call and the loading element the same way
+/// ViewerPerfmonShapingParityTests pins Viewer/Lite parity — a source scan, not a rendered control.
+/// The label text itself is pinned once, off this call path, by JobHistoryCapLabelTests.
+///
+public sealed class LiteJobHistoryLoadingCapTests
+{
+ [Fact]
+ public void LiteJobHistoryTab_CallsTheSharedCapLabel_AndDeclaresALoadingElement()
+ {
+ var cs = RepoFile.ReadRepoFile("Lite", "Controls", "JobHistoryTab.xaml.cs");
+ var stripped = CSharpSourceWalker.StripCommentsAndStrings(cs);
+
+ Assert.Contains("JobHistoryCap.Label(", stripped, StringComparison.Ordinal);
+
+ /* The loading element is shown before the read starts and collapsed in a finally, so it clears on
+ every exit path (success, the caught exception, and a superseded/early return). */
+ var head = stripped.IndexOf("private async System.Threading.Tasks.Task LoadJobsAsync()", StringComparison.Ordinal);
+ Assert.True(head >= 0, "LoadJobsAsync was not found in Lite/Controls/JobHistoryTab.xaml.cs");
+ var open = stripped.IndexOf('{', head);
+ var body = CSharpSourceWalker.BraceBalanced(stripped, open);
+
+ Assert.Contains("LoadingMessage.Visibility = Visibility.Visible;", body, StringComparison.Ordinal);
+
+ /* #4478/#4488: an unconditional finally collapsed the loading message for a SUPERSEDED load too, so
+ the earlier of two overlapping reads could hide the newer load's indicator. The collapse on both
+ exit paths (success and the caught exception) now sits behind the same _loads.Superseded check as
+ every other paint in this method — asserting on that shape rather than on a bare finally, which is
+ exactly the shape that was wrong. */
+ var collapseCount = Regex.Matches(body, @"LoadingMessage\s*\.\s*Visibility\s*=\s*Visibility\s*\.\s*Collapsed\s*;").Count;
+ Assert.True(collapseCount >= 2, "LoadJobsAsync should collapse the loading message on both the success and the caught-exception exit path");
+
+ var guardedCollapses = Regex.Matches(
+ body,
+ @"if\s*\(\s*_loads\s*\.\s*Superseded\s*\(\s*nameof\s*\(\s*LoadJobsAsync\s*\)\s*,\s*gen\s*\)\s*\)\s*return\s*;\s*\r?\n\s*\r?\n?\s*LoadingMessage\s*\.\s*Visibility\s*=\s*Visibility\s*\.\s*Collapsed\s*;").Count;
+ Assert.True(
+ guardedCollapses >= 2,
+ "LoadJobsAsync's loading-message collapse must sit behind its own supersession check, not run unconditionally for a superseded load");
+ }
+
+ [Fact]
+ public void LiteJobHistoryXaml_DeclaresTheLoadingElement()
+ {
+ var xaml = RepoFile.ReadRepoFile("Lite", "Controls", "JobHistoryTab.xaml");
+
+ Assert.Contains("x:Name=\"LoadingMessage\"", xaml, StringComparison.Ordinal);
+ }
+}
diff --git a/Darling/Darling.Tests/ScopedLoadOrderingTests.cs b/Darling/Darling.Tests/ScopedLoadOrderingTests.cs
index 88bb81f7a..8fda165ca 100644
--- a/Darling/Darling.Tests/ScopedLoadOrderingTests.cs
+++ b/Darling/Darling.Tests/ScopedLoadOrderingTests.cs
@@ -225,6 +225,9 @@ the grid they scope. */
new Site(s_finOps, "JobHistoryTab.xaml.cs", "Task", "LoadJobsAsync"),
new Site(s_finOps, "JobHistoryTab.xaml.cs", "Task", "UpdateAgentStatusAsync"),
+ /* The Viewer's twin (#4478/#4488) — same fleet-wide read, same missing guard on its loading paint. */
+ new Site(s_viewer, "JobHistoryTab.xaml.cs", "Task", "LoadJobsAsync"),
+
/* #2929's second and third sites, now ordered as well as scope-verified. Both twins, because the
same-scope hole is identical in them and fixing one half is how a parity gap starts. */
new Site(s_liteWindows, "DatabaseStateOverridesWindow.xaml.cs", "Task", "LoadAsync"),
@@ -521,7 +524,7 @@ public void NoSiteClaims_AboveAReturnThatPaintsNothing()
// ── Site mechanics ────────────────────────────────────────────────────────────────────────
private static void AssertEverySiteIsStillHere() =>
- Assert.True(s_sites.Length == 27, $"{s_sites.Length} site(s) in the table; this pin covers 27");
+ Assert.True(s_sites.Length == 28, $"{s_sites.Length} site(s) in the table; this pin covers 28");
/// A XAML-generated control and one of the UI properties a load paints into.
private const string PaintTarget =
diff --git a/Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs b/Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs
index af046c5c2..d0c2c0ac1 100644
--- a/Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs
+++ b/Darling/PerformanceMonitor.Darling.Viewer/JobHistoryTab.xaml.cs
@@ -31,6 +31,7 @@ public partial class JobHistoryTab : UserControl
{
private ViewerDataService? _dataService;
private DataGridFilterManager? _filterManager;
+ private readonly PerformanceMonitor.Ui.ScopedLoadGenerations _loads = new();
private Popup? _filterPopup;
private ColumnFilterPopup? _filterPopupContent;
private DateTime? _lastRefreshed;
@@ -68,6 +69,8 @@ private async Task LoadJobsAsync()
return;
}
+ var gen = _loads.Claim(nameof(LoadJobsAsync));
+
NoJobsMessage.Visibility = Visibility.Collapsed;
LoadingMessage.Visibility = Visibility.Visible;
@@ -78,6 +81,7 @@ private async Task LoadJobsAsync()
var sinceUtc = DateTime.UtcNow.AddHours(-hoursBack);
var all = await _dataService.GetJobHistoryAsync(sinceUtc, serverId, RowCap);
+ if (_loads.Superseded(nameof(LoadJobsAsync), gen)) return;
/* Populate the Server / Category combos from the full (pre status/category) result, then apply
Status + Category client-side — those must NOT go into the reader's window (they'd skew the
@@ -118,13 +122,15 @@ the underlying read still hit the cap. */
UpdateStaleDataIndicator();
await UpdateAgentStatusAsync(serverId);
+ if (_loads.Superseded(nameof(LoadJobsAsync), gen)) return;
+
+ LoadingMessage.Visibility = Visibility.Collapsed;
}
catch (Exception ex)
{
StatusChanged?.Invoke($"failed to load job history: {ex.Message}");
- }
- finally
- {
+ if (_loads.Superseded(nameof(LoadJobsAsync), gen)) return;
+
LoadingMessage.Visibility = Visibility.Collapsed;
}
}
diff --git a/Lite/Controls/JobHistoryTab.xaml b/Lite/Controls/JobHistoryTab.xaml
index 1d541e014..9918dfa07 100644
--- a/Lite/Controls/JobHistoryTab.xaml
+++ b/Lite/Controls/JobHistoryTab.xaml
@@ -206,6 +206,14 @@
FontSize="14" Foreground="{DynamicResource ForegroundMutedBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Visibility="Collapsed"/>
+
+
+
diff --git a/Lite/Controls/JobHistoryTab.xaml.cs b/Lite/Controls/JobHistoryTab.xaml.cs
index d6dfaf128..d5a46254f 100644
--- a/Lite/Controls/JobHistoryTab.xaml.cs
+++ b/Lite/Controls/JobHistoryTab.xaml.cs
@@ -66,6 +66,10 @@ public async void RefreshJobs()
await LoadJobsAsync();
}
+ /// The read's row cap (#4478) — the 2,000 passes to
+ /// .
+ private const int RowCap = 2000;
+
private async System.Threading.Tasks.Task LoadJobsAsync()
{
if (_dataService == null) return;
@@ -74,12 +78,15 @@ private async System.Threading.Tasks.Task LoadJobsAsync()
starting of two overlapping reads can land first. Same idiom as FinOpsTab's. */
var gen = _loads.Claim(nameof(LoadJobsAsync));
+ NoJobsMessage.Visibility = Visibility.Collapsed;
+ LoadingMessage.Visibility = Visibility.Visible;
+
try
{
var hoursBack = GetSelectedHoursBack();
int? serverId = GetSelectedServerId();
- var all = await System.Threading.Tasks.Task.Run(() => _dataService.GetJobHistoryAsync(hoursBack, 2000, serverId));
+ var all = await System.Threading.Tasks.Task.Run(() => _dataService.GetJobHistoryAsync(hoursBack, RowCap, serverId));
if (_loads.Superseded(nameof(LoadJobsAsync), gen)) return;
/* #2126: rows carry the raw collected server name; swap in the operator's alias where the
@@ -118,17 +125,30 @@ other tab. A server no longer in config keeps its raw name (the durable-record c
var displayCount = JobHistoryDataGrid.ItemsSource is ICollection coll ? coll.Count : filtered.Count;
NoJobsMessage.Visibility = displayCount == 0 ? Visibility.Visible : Visibility.Collapsed;
- JobCountIndicator.Text = displayCount > 0 ? $"{displayCount} run(s)" : "";
+
+ /* The cap applies to the UNFILTERED read (all.Count), not the client-side-filtered display count:
+ a Status/Category filter narrowing the grid must not make the "newest 2,000" label disappear when
+ the underlying read still hit the cap. */
+ var capLabel = JobHistoryCap.Label(all.Count, RowCap);
+ JobCountIndicator.Text = displayCount == 0
+ ? ""
+ : capLabel.Length > 0 ? $"{displayCount} run(s) ({capLabel})" : $"{displayCount} run(s)";
AppLogger.Debug("JobHistory", $"Loaded {displayCount} job run(s) (query returned {all.Count}, hoursBack={hoursBack}, serverId={serverId?.ToString() ?? "all"})");
_lastRefreshed = DateTime.UtcNow;
UpdateStaleDataIndicator();
await UpdateAgentStatusAsync(serverId);
+ if (_loads.Superseded(nameof(LoadJobsAsync), gen)) return;
+
+ LoadingMessage.Visibility = Visibility.Collapsed;
}
catch (Exception ex)
{
AppLogger.Error("JobHistory", $"Failed to load job history: {ex.Message}");
+ if (_loads.Superseded(nameof(LoadJobsAsync), gen)) return;
+
+ LoadingMessage.Visibility = Visibility.Collapsed;
}
}