diff --git a/Darling/Darling.Tests/DarlingFleetReaderTests.cs b/Darling/Darling.Tests/DarlingFleetReaderTests.cs
index f1c6376f5b..de9e5f86cc 100644
--- a/Darling/Darling.Tests/DarlingFleetReaderTests.cs
+++ b/Darling/Darling.Tests/DarlingFleetReaderTests.cs
@@ -208,9 +208,11 @@ public void FleetLastCollectionSql_IsWindowed_NotAFullHistoryScan()
Assert.Contains("collection_time >= $1", sql, StringComparison.Ordinal);
/* #3895: bounded is not enough — as a GROUP BY the 48 hours still aggregated every collector run of
every server in them (490,940 rows on DARLING01) for one timestamp each. One probe per enabled
- registry server, the window kept so a long-dark server still falls out exactly as it did. */
+ registry server, the window kept. #3935 made the join LEFT: a long-dark server is reported with an
+ empty window rather than dropped, so the card can tell it from one never collected
+ (FleetCardTellsDarkFromNeverCollectedTests). */
Assert.Contains("FROM servers AS s", sql, StringComparison.Ordinal);
- Assert.Contains("CROSS JOIN LATERAL", sql, StringComparison.Ordinal);
+ Assert.Contains("LEFT JOIN LATERAL", sql, StringComparison.Ordinal);
Assert.Matches(@"ORDER BY collection_time DESC\s+LIMIT 1", sql);
Assert.Contains("WHERE s.is_enabled", sql, StringComparison.Ordinal);
Assert.Contains("s.server_id <> 0", sql, StringComparison.Ordinal);
diff --git a/Darling/Darling.Tests/FleetCardCollectionStaleNamesItsPopulationTests.cs b/Darling/Darling.Tests/FleetCardCollectionStaleNamesItsPopulationTests.cs
index bc35e729e2..b7b539ca96 100644
--- a/Darling/Darling.Tests/FleetCardCollectionStaleNamesItsPopulationTests.cs
+++ b/Darling/Darling.Tests/FleetCardCollectionStaleNamesItsPopulationTests.cs
@@ -172,6 +172,11 @@ exactly as they are in the payload. */
/// age, explode that band into the shared flags, take the flag, put it on the card. There is no error
/// count in the chain, no collection_log read, and no use of the roll-up's published window — which
/// is why the card's window_start / window_end never bounded it.
+ ///
+ /// #3935 routed the classification through ClassifyWindowedFreshness, which adds the
+ /// registration instant and one arm, Offline. It cannot produce Stale, so this flag's population is
+ /// unchanged;
+ /// holds that over the combinations.
///
[Fact]
public void TheMcpCard_DerivesTheFlagFromFreshnessAndNothingElse()
@@ -180,7 +185,7 @@ public void TheMcpCard_DerivesTheFlagFromFreshnessAndNothingElse()
"Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingFleetReader.cs");
Assert.Contains(
- "var freshness = ServerHealthClassifier.ClassifyFreshness(lastCollection, now);\n"
+ "var freshness = ClassifyWindowedFreshness(lastCollection, registeredAt, now);\n"
+ " var flags = ServerCollectionStatusRules.FlagsFor(freshness);",
reader, StringComparison.Ordinal);
Assert.Contains("var collectionStale = flags.CollectionStale;", reader, StringComparison.Ordinal);
diff --git a/Darling/Darling.Tests/FleetCardTellsDarkFromNeverCollectedTests.cs b/Darling/Darling.Tests/FleetCardTellsDarkFromNeverCollectedTests.cs
new file mode 100644
index 0000000000..9bab625693
--- /dev/null
+++ b/Darling/Darling.Tests/FleetCardTellsDarkFromNeverCollectedTests.cs
@@ -0,0 +1,235 @@
+/*
+ * 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.Linq;
+using System.Text.RegularExpressions;
+using PerformanceMonitor.Collectors;
+using PerformanceMonitor.Common;
+using PerformanceMonitor.Darling.Service.Mcp;
+using Xunit;
+
+namespace Darling.Tests;
+
+///
+/// #3935: the fleet card tells a server that went dark before its last-collection window apart from one that
+/// has never collected. The window is 48 hours and stays (a probe with none plans every retained chunk of
+/// collection_log); a server silent for longer used to reach the card as a null, and a null is what
+/// calls never collected — "Awaiting first collection",
+/// banded Warning — while the WPF viewer, reading the same timestamp with no window, called it Offline.
+///
+/// What decides it. The last-collection read reports every enabled registry server, with the
+/// registry's created_date (the first successful connect) beside a newest collection that is null
+/// when the window holds none. Registered before the window: Offline. Registered inside it: never collected,
+/// exactly, because the worker collects only after connecting. These pins hold the rule, its boundary, the
+/// card and the reason it produces, and the read's shape;
+/// holds the seam against a store.
+///
+public sealed class FleetCardTellsDarkFromNeverCollectedTests
+{
+ private static readonly DateTime Now = new(2026, 9, 23, 12, 0, 0, DateTimeKind.Utc);
+
+ private static DateTime WindowStart => DarlingFleetReader.LastCollectionWindowStart(Now);
+
+ ///
+ /// The windowed ladder is the shared one plus ONE arm. Every newest collection the window holds bands
+ /// exactly as bands it, whatever the registration
+ /// says; an empty window is Offline only on a registration older than the window, and keeps the shared
+ /// reading (never collected) on one inside it or on none at all.
+ ///
+ [Fact]
+ public void TheWindowedLadder_DepartsFromTheSharedOne_OnlyForAnEmptyWindowOnAnOlderRegistration()
+ {
+ var registrations = new DateTime?[]
+ {
+ null,
+ Now.AddDays(-30),
+ WindowStart.AddTicks(-1),
+ WindowStart,
+ Now.AddMinutes(-10),
+ };
+ var collections = new DateTime?[]
+ {
+ Now.AddSeconds(-30), // fresh
+ Now - ServerHealthThresholds.StaleThreshold - TimeSpan.FromMinutes(1), // stale
+ Now - ServerHealthThresholds.OfflineThreshold - TimeSpan.FromHours(3), // dark, inside the window
+ WindowStart, // the window's first instant
+ };
+
+ foreach (var registeredAt in registrations)
+ {
+ foreach (var lastCollection in collections)
+ {
+ Assert.Equal(
+ ServerHealthClassifier.ClassifyFreshness(lastCollection, Now),
+ DarlingFleetReader.ClassifyWindowedFreshness(lastCollection, registeredAt, Now));
+ }
+ }
+
+ Assert.Equal(ServerFreshness.Offline, DarlingFleetReader.ClassifyWindowedFreshness(null, Now.AddDays(-30), Now));
+ Assert.Equal(ServerFreshness.NeverCollected, DarlingFleetReader.ClassifyWindowedFreshness(null, Now.AddMinutes(-10), Now));
+ Assert.Equal(ServerFreshness.NeverCollected, DarlingFleetReader.ClassifyWindowedFreshness(null, null, Now));
+ }
+
+ ///
+ /// The boundary is the instant the read's $1 is bound to. A server registered AT the window's start
+ /// could only have collected at or after it, which is inside the window the read searched (its predicate
+ /// is collection_time >= $1), so an empty window proves it never has. One tick earlier and a
+ /// collection outside the window is possible, so it is dark.
+ ///
+ [Fact]
+ public void TheBoundary_IsTheInstantTheReadIsBoundTo()
+ {
+ Assert.Equal(ServerFreshness.NeverCollected, DarlingFleetReader.ClassifyWindowedFreshness(null, WindowStart, Now));
+ Assert.Equal(ServerFreshness.Offline, DarlingFleetReader.ClassifyWindowedFreshness(null, WindowStart.AddTicks(-1), Now));
+
+ /* One derivation for the bind and the rule: two days, as naive UTC (the store's convention — a
+ Kind=Utc value binds as timestamptz and PostgreSQL zone-shifts it against the naive column). */
+ Assert.Equal(TimeSpan.FromHours(48), DarlingFleetReader.LastCollectionWindow);
+ Assert.Equal(new DateTime(2026, 9, 21, 12, 0, 0), WindowStart);
+ Assert.Equal(DateTimeKind.Unspecified, WindowStart.Kind);
+ }
+
+ ///
+ /// A server the read did not report at all is handed the row's default, two nulls, and keeps the
+ /// no-claim reading. That is the lesson of the rotating false-Offlines on actively-collecting servers
+ /// that the reader's null fallback was written for: an ABSENCE never becomes a red Offline. Only the
+ /// read's positive report of an empty window can.
+ ///
+ [Fact]
+ public void AServerTheReadDidNotReport_KeepsTheNoClaimReading()
+ {
+ var missing = default(DarlingFleetReader.LastCollectionRow);
+
+ Assert.Null(missing.LastCollection);
+ Assert.Null(missing.RegisteredAt);
+ Assert.Equal(
+ ServerFreshness.NeverCollected,
+ DarlingFleetReader.ClassifyWindowedFreshness(missing.LastCollection, missing.RegisteredAt, Now));
+
+ var card = Card(missing.LastCollection, missing.RegisteredAt);
+ Assert.NotEqual(FleetHealthBand.Offline, card.Band);
+ Assert.NotEqual(false, card.IsOnline);
+ }
+
+ ///
+ /// The issue's card: registered weeks ago, nothing in the window. It reads Offline on every field a
+ /// consumer keys on — the band, the status word, is_online, the awaiting flag — and the
+ /// "needs attention" reason says so, where it used to say "Awaiting first collection". The window's
+ /// newest collection is null because there is none; status is what tells this null from the
+ /// never-collected one.
+ ///
+ [Fact]
+ public void ADarkServersCard_ReadsOffline_NotAwaitingItsFirstCollection()
+ {
+ var card = Card(null, Now.AddDays(-30));
+
+ Assert.Equal(FleetHealthBand.Offline, card.Band);
+ Assert.Equal(ServerCollectionStatus.Offline.Word(), card.Status);
+ Assert.False(card.IsOnline);
+ Assert.False(card.AwaitingFirstCollection);
+ Assert.False(card.CollectionStale);
+ Assert.Null(card.LastCollectionTime);
+ Assert.Equal("Offline - no recent collection", DarlingFleetReader.BuildReason(card));
+
+ /* And the roll-up counts it where the viewer's would: Offline, not Warning. */
+ var rollup = DarlingFleetReader.BuildRollup(new[] { card }, Now, Now.AddHours(-1), Now);
+ Assert.Equal(1, rollup.OfflineCount);
+ Assert.Equal(0, rollup.WarningCount);
+ Assert.Equal(FleetHealthBand.Offline, Assert.Single(rollup.WorstServers).Band);
+ }
+
+ ///
+ /// The other half, and the reason the registry is consulted at all rather than calling every empty
+ /// window Offline: a server the service has just connected to and not yet collected from is the bootstrap
+ /// state, and a red Offline on it is what sent a 24-server field report chasing a phantom scheduler bug
+ /// ().
+ ///
+ [Fact]
+ public void ANewlyRegisteredServersCard_StillReadsAwaitingFirstCollection()
+ {
+ var card = Card(null, Now.AddMinutes(-10));
+
+ Assert.Equal(FleetHealthBand.Warning, card.Band);
+ Assert.Equal(ServerCollectionStatus.AwaitingFirstCollection.Word(), card.Status);
+ Assert.Null(card.IsOnline);
+ Assert.True(card.AwaitingFirstCollection);
+ Assert.Equal(ServerCollectionStatus.AwaitingFirstCollection.Word(), DarlingFleetReader.BuildReason(card));
+ }
+
+ ///
+ /// The new arm produces Offline and nothing else, so collection_stale keeps exactly the population
+ /// its name claims (#3098): the Stale band, which only a collection inside the window can produce.
+ ///
+ [Fact]
+ public void TheCollectionStaleFlag_KeepsItsPopulation()
+ {
+ foreach (var registeredAt in new DateTime?[] { null, Now.AddDays(-30), Now.AddMinutes(-10) })
+ {
+ foreach (var age in new TimeSpan?[] { null, TimeSpan.FromSeconds(5), ServerHealthThresholds.StaleThreshold + TimeSpan.FromMinutes(1), TimeSpan.FromHours(20) })
+ {
+ DateTime? lastCollection = age.HasValue ? Now - age.Value : null;
+ Assert.Equal(
+ ServerCollectionStatusRules.FlagsFor(ServerHealthClassifier.ClassifyFreshness(lastCollection, Now)).CollectionStale,
+ Card(lastCollection, registeredAt).CollectionStale);
+ }
+ }
+ }
+
+ ///
+ /// The read's shape: still the per-server probe #3895 made it, still windowed on the partition column,
+ /// but a LEFT join, so a server whose window is empty is REPORTED with a null rather than dropped, and the
+ /// registration rides the same row. It reads collection_log exactly once; the discriminator is a
+ /// registry column, not a second, unwindowed look at the history (the price the window exists to avoid).
+ ///
+ [Fact]
+ public void TheLastCollectionRead_ReportsEveryEnabledServer_WithItsRegistration()
+ {
+ var sql = DarlingFleetReader.FleetLastCollectionSql;
+
+ Assert.Contains("FROM servers AS s", sql, StringComparison.Ordinal);
+ Assert.Contains("LEFT JOIN LATERAL", sql, StringComparison.Ordinal);
+ Assert.Contains(") AS latest ON TRUE", sql, StringComparison.Ordinal);
+ Assert.Contains("s.created_date", sql, StringComparison.Ordinal);
+ Assert.Contains("collection_time >= $1", sql, StringComparison.Ordinal);
+ Assert.Matches(@"ORDER BY collection_time DESC\s+LIMIT 1", sql);
+ Assert.Single(Regex.Matches(sql, @"\bv_collection_log\b"));
+ Assert.DoesNotContain("CROSS JOIN", sql, StringComparison.Ordinal);
+ Assert.DoesNotContain("GROUP BY", sql, StringComparison.Ordinal);
+ Assert.True(FleetReadsAreBoundedByTheFleetTests.IsBoundedByTheFleet(sql));
+
+ /* The reader binds the window through the same derivation the rule uses, so the window the read
+ searched and the window the card reasons about cannot drift apart: a bind narrower than the rule
+ would call a server that collected between the two "never collected". */
+ var reader = RepoFile.ReadRepoFile("Darling", "PerformanceMonitor.Darling.Service", "Mcp", "DarlingFleetReader.cs");
+ Assert.Contains("AddTimestamp(command, LastCollectionWindowStart(now));", reader, StringComparison.Ordinal);
+ Assert.DoesNotContain("AddHours(-48)", reader, StringComparison.Ordinal);
+ }
+
+ /// A calm SQL Server card whose only variables are the two freshness inputs, assembled by the
+ /// reader's own .
+ private static FleetServerCard Card(DateTime? lastCollection, DateTime? registeredAt) =>
+ DarlingFleetReader.BuildCard(
+ new DarlingFleetReader.FleetServerRow(1, "sql-01", "sql-01", 3, MonitoredEngineKind.SqlServer, false),
+ new DarlingFleetReader.CpuRow(20, 5),
+ default,
+ default,
+ default,
+ new DarlingFleetReader.ThreadsRow(512, 100, 0, 0),
+ default,
+ default,
+ default,
+ lastCollection,
+ /* Forty collectors banded, none failing: the collectors row is a measured reading (#3539 A6). */
+ new DarlingFleetReader.CollectorCounts(40, 0, 40, 0),
+ null,
+ Now,
+ TimeSpan.FromHours(1),
+ DeadlockRateThresholds.Default,
+ registeredAt);
+}
diff --git a/Darling/Darling.Tests/FleetReadsAreBoundedByTheFleetTests.cs b/Darling/Darling.Tests/FleetReadsAreBoundedByTheFleetTests.cs
index 97a33cdfc4..ca6ef97c92 100644
--- a/Darling/Darling.Tests/FleetReadsAreBoundedByTheFleetTests.cs
+++ b/Darling/Darling.Tests/FleetReadsAreBoundedByTheFleetTests.cs
@@ -236,7 +236,9 @@ private static Dictionary FleetStatements() =>
/// Sentinel servers, negative ids, cleaned up in finally: two collecting SQL Servers (one with a
/// ring-buffer batch whose samples share a collection instant), one registered and never collected, one dark for
/// five days, one never stamped with an engine, one disabled, and one PostgreSQL target carrying a planted
-/// SQL Server row it could never really have — which is how the driver's engine filter is shown to be real.
+/// SQL Server row it could never really have — which is how the driver's engine filter is shown to be real.
+/// Each is registered the way the service registers one, with the first-connect instant #3935's fleet card
+/// reads to tell the dark server from the never-collected one.
///
[Collection("live-postgres")]
public sealed class FleetReadsAreBoundedLivePostgresTests
@@ -356,7 +358,8 @@ WHERE server_id <> 0
/// over a week of history and every sentinel shape — including a CPU batch whose samples tie on the
/// collection instant (the tiebreak must still pick the newest sample), grant pools summed at the newest
/// instant, a server dark for five days (the unbounded probe must still find it; the 48-hour read must still
- /// drop it), and incident rows collected late and collected ahead of their own event's clock.
+ /// leave its collection out, and since #3935 report it with an empty window and its registration), and
+ /// incident rows collected late and collected ahead of their own event's clock.
///
[Fact]
public async Task TheBoundedReads_ReturnTheUnboundedReadsRows_AgainstDevPostgres()
@@ -437,13 +440,22 @@ collection is stamped MORE than a chunk before the event itself — a monitored
Assert.Single(oldDeadlocks);
Assert.Empty(newDeadlocks);
- /* Last collection: the 48 hours kept, so the five-days-dark server falls out of both. */
- var lastArgs = new object[] { now.AddHours(-48) };
+ /* Last collection: the 48 hours kept, so the five-days-dark server's collection is outside the window
+ in both. #3935: the probe REPORTS that server, and the never-collected one, with an empty window
+ and the registration beside it, where the oracle simply has no row; every collection the oracle
+ finds, the probe finds identically. */
+ var lastArgs = new object[] { DarlingFleetReader.LastCollectionWindowStart(now) };
var oldLast = Scoped(await ReadRowsAsync(connection, OldLastCollectionSql, lastArgs, ct), s_compared);
var newLast = Scoped(await ReadRowsAsync(connection, DarlingFleetReader.FleetLastCollectionSql, lastArgs, ct), s_compared);
- Assert.Equal(oldLast, newLast);
- Assert.Equal(3, newLast.Count);
- Assert.DoesNotContain(newLast, r => r.StartsWith(Id(DarkFiveDays), StringComparison.Ordinal));
+ Assert.Equal(3, oldLast.Count);
+ Assert.Equal(oldLast, newLast.Where(r => !r.Contains("|null|", StringComparison.Ordinal)).Select(WithoutLastCell).ToList());
+ Assert.Equal(s_compared.Length, newLast.Count);
+ Assert.Contains(Id(DarkFiveDays) + "null|" + Stamp(RegisteredLongAgo(now)), newLast);
+ Assert.Contains(Id(NeverCollected) + "null|" + Stamp(RegisteredJustNow(now)), newLast);
+ Assert.Contains(Id(CollectingA) + Stamp(now.AddMinutes(-1)) + "|" + Stamp(RegisteredLongAgo(now)), newLast);
+ Assert.DoesNotContain(
+ await ReadRowsAsync(connection, DarlingFleetReader.FleetLastCollectionSql, lastArgs, ct),
+ r => r.StartsWith(Id(Disabled), StringComparison.Ordinal));
/* The WPF viewer's sidebar freshness: every REGISTERED server, disabled included, unbounded — so the
dark and the disabled servers keep their real last collection. */
@@ -474,7 +486,9 @@ dark and the disabled servers keep their real last collection. */
/// The seam: the overview itself, through , puts the
/// newest reading on each card — the newest sample of a tied batch, the pools summed at the newest instant,
/// a dark server's last values — leaves a never-collected server's metrics empty, and ignores the reading
- /// planted under the PostgreSQL target.
+ /// planted under the PostgreSQL target. And (#3935) it bands the five-days-dark server Offline and the
+ /// just-registered one awaiting its first collection, the same words the WPF viewer's unbounded read gives
+ /// them.
///
[Fact]
public async Task TheOverview_BuildsEachCardFromItsNewestReading_AgainstDevPostgres()
@@ -523,7 +537,15 @@ public async Task TheOverview_BuildsEachCardFromItsNewestReading_AgainstDevPostg
var dark = cards[DarkFiveDays];
Assert.Equal(11, dark.CpuPercent); // five days old, still its newest
Assert.Equal(1100, dark.MemoryMb);
- Assert.NotEqual(true, dark.IsOnline);
+
+ /* #3935: dark for five days, registered nine days ago — Offline, the viewer's word for it, and not
+ "Awaiting first collection". Its last collection is outside the kept window, so the card carries
+ none; status is what tells this null from the never-collected one below. */
+ Assert.False(dark.IsOnline);
+ Assert.Equal(FleetHealthBand.Offline, dark.Band);
+ Assert.Equal(ServerCollectionStatus.Offline.Word(), dark.Status);
+ Assert.False(dark.AwaitingFirstCollection);
+ Assert.Null(dark.LastCollectionTime);
var never = cards[NeverCollected];
Assert.Null(never.CpuPercent);
@@ -531,6 +553,26 @@ public async Task TheOverview_BuildsEachCardFromItsNewestReading_AgainstDevPostg
Assert.Null(never.TotalThreads);
Assert.Null(never.GrantedMemoryMb);
+ /* Registered ten minutes ago and nothing collected since: still the bootstrap state, never red. */
+ Assert.Null(never.IsOnline);
+ Assert.True(never.AwaitingFirstCollection);
+ Assert.Equal(FleetHealthBand.Warning, never.Band);
+ Assert.Equal(ServerCollectionStatus.AwaitingFirstCollection.Word(), never.Status);
+
+ /* And both read what the WPF viewer reads for them: its sidebar freshness has no window, and each
+ card's status is the word that freshness bands to. */
+ await using (var viewer = new ViewerDataService(connectionString!))
+ {
+ var viewerFreshness = await viewer.GetServerFreshnessAsync(ct);
+ foreach (var id in new[] { DarkFiveDays, NeverCollected, CollectingA })
+ {
+ DateTime? viewerLast = viewerFreshness.TryGetValue(id, out var seen) ? seen : null;
+ Assert.Equal(
+ ServerCollectionStatusRules.FromFreshness(ServerHealthClassifier.ClassifyFreshness(viewerLast, now)).Word(),
+ cards[id].Status);
+ }
+ }
+
var pg = cards[PostgresTarget];
Assert.Null(pg.MemoryMb); // the planted SQL Server row is never read
@@ -603,6 +645,16 @@ seeded week's older days are not among the ones it plans. */
Assert.True(flooredChunks <= unflooredChunks - 5,
$"the floored count planned {flooredChunks} of the oracle's {unflooredChunks} chunks for a one-hour window:\n{floored}");
+ /* #3935 kept the last-collection read's window and told a dark server from a never-collected one
+ with a registry column instead, because the same probe with the window taken out plans every
+ retained chunk of collection_log on every call. Pinned at the plan, so a fix that drops the window
+ goes red here: the windowed read plans only the window's chunks. */
+ var windowedLast = await ExplainAsync(connection, "EXPLAIN (COSTS OFF) " + DarlingFleetReader.FleetLastCollectionSql, new object[] { DarlingFleetReader.LastCollectionWindowStart(now) }, ct);
+ var unwindowedLast = await ExplainAsync(connection, "EXPLAIN (COSTS OFF) " + UnwindowedLastCollectionSql(), Array.Empty