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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ That paragraph is not decoration: the release workflow copies each entry verbati
the GitHub release body and the announcement discussion, so it is the first thing a
prospective user reads. CI fails a pull request whose newest entry is missing it.

## [1.76.11] - 2026-09-04

If Windows cannot report your drive's health — common on desktop SATA and NVMe drives, and always the case in
a virtual machine — the Landing tab said "Disk health degrading" and showed a warning. Nothing was degrading.
It now says the health could not be read, which is what actually happened.

### Fixed
- **A drive whose health cannot be read is no longer reported as degrading.** SysManager scores an unmeasured
drive cautiously on purpose, so it can never be called healthy without evidence. But that cautious score was
then read as a verdict, and the warning band starts just below it — so a machine that reported no drive
health at all was told its disk was on the way out. The tab now distinguishes "could not read this" from "we
read it and it looks worse than it should", and only the second one warns. A machine with one readable drive
and one unreadable one still reports the readable drive's verdict, so a genuinely failing disk is never
hidden behind a "could not read" message.

## [1.76.10] - 2026-09-04

The "can be freed" and "in Recycle Bin" figures on the Cleanup tab could stop counting partway through a
Expand Down
50 changes: 50 additions & 0 deletions SysManager/SysManager.Tests/HealthScoreServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,56 @@ public void ComputeDiskScore_DiskWithNoDataAtAll_ScoresTheUnknownValue()

Assert.Equal(80, HealthScoreService.ComputeDiskScore(disks));
}

// ---------- UnavailableComponents ----------

[Fact]
public void UnavailableComponents_DrivesPresentButNoneReadable_MarksTheDiskUnavailable()
{
// The score alone cannot carry this. Two drives with no SMART data score the deliberate unknown 80,
// and ClassifySmartHealth reads 80 with unavailable=false through its `>= 60` branch as "Disk health
// degrading" — a claim about failing hardware on a machine where nothing was measured. The test
// directly below UnknownComponentScore_StaysBelowEveryGreenBranch asserts that intent in prose
// ("must not read as degrading either — nothing was measured") while this path delivered exactly that.
var disks = new List<DiskHealthReport>
{
new() { FriendlyName = "Samsung SSD", HealthStatus = "" },
new() { FriendlyName = "WDC HDD", HealthStatus = "" }
};
Assert.All(disks, d => Assert.Null(d.HealthPercent)); // the premise, not an assumption

var unavailable = HealthScoreService.UnavailableComponents(disks, null);

Assert.Contains(HealthScoreService.DiskComponent, unavailable);
}

[Fact]
public void UnavailableComponents_OneReadableDriveAmongUnreadable_KeepsTheDiskAvailable()
{
// The negative half, and the reason the rule is All rather than Any: marking the component
// unavailable here would replace "Disk health critical" with "could not be read" and hide a drive
// Windows has already flagged as failing.
var disks = new List<DiskHealthReport>
{
new() { FriendlyName = "Failing drive", HealthStatus = "Unhealthy" },
new() { FriendlyName = "Unreadable drive", HealthStatus = "" }
};

var unavailable = HealthScoreService.UnavailableComponents(disks, null);

Assert.DoesNotContain(HealthScoreService.DiskComponent, unavailable);
Assert.Equal(20, HealthScoreService.ComputeDiskScore(disks)); // and the failing verdict survives
}

[Fact]
public void UnavailableComponents_NoDrivesAtAll_StillMarksTheDiskUnavailable()
{
Assert.Contains(HealthScoreService.DiskComponent,
HealthScoreService.UnavailableComponents([], null));
Assert.Contains(HealthScoreService.DiskComponent,
HealthScoreService.UnavailableComponents(null, null));
}

[Fact]
public void UnknownComponentScore_StaysBelowEveryGreenBranch()
{
Expand Down
42 changes: 35 additions & 7 deletions SysManager/SysManager/Services/HealthScoreService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,7 @@ public async Task<HealthScoreResult> ComputeAsync(CancellationToken ct = default
// Recorded so a consumer can say "could not read this" instead of reading a verdict out of a
// fallback number. The scores above already refuse to claim health; this is what makes the reason
// visible.
List<string> unavailable = [];
if (disks is null || disks.Count == 0) unavailable.Add(DiskComponent);
if (snapshot is null)
{
unavailable.Add(MemoryComponent);
unavailable.Add(UptimeComponent);
}
var unavailable = UnavailableComponents(disks, snapshot);

return new HealthScoreResult
{
Expand All @@ -114,6 +108,40 @@ public async Task<HealthScoreResult> ComputeAsync(CancellationToken ct = default
};
}

/// <summary>
/// Which components produced no usable evidence, so a consumer can say "could not read this" instead of
/// reading a verdict out of a fallback number. The scores already refuse to claim health; this is what
/// makes the reason visible.
/// </summary>
/// <remarks>
/// Pure and internal for the same reason <see cref="ComputeDiskScore"/> is: the decision is worth
/// asserting, and asserting it through <see cref="ComputeAsync"/> would mean querying WMI.
/// <para>Drives present but none readable is the same absence of evidence as no drives at all, and it is
/// the common case — plenty of consumer SATA and NVMe disks expose nothing through
/// <c>MSFT_StorageReliabilityCounter</c>, and a VM exposes nothing whatever. Testing only for an empty
/// list left that machine scored at the deliberate unknown 80, which
/// <c>DashboardViewModel.ClassifySmartHealth</c> then reads through its <c>&gt;= 60</c> branch as "Disk
/// health degrading" — the outcome that method's own remarks rule out, because nothing is degrading when
/// nothing was measured.</para>
/// <para>Deliberately <c>All</c>, not <c>Any</c>. With one readable drive at 30% and one unreadable,
/// <c>Any</c> would mark the component unavailable and replace a critical-disk warning with "could not be
/// read", hiding a failing drive. A mixed read keeps the worst measured verdict. <c>All</c> also covers
/// the empty list, which is why that case is no longer spelled out.</para>
/// </remarks>
internal static List<string> UnavailableComponents(
IReadOnlyList<DiskHealthReport>? disks, SystemSnapshot? snapshot)
{
List<string> unavailable = [];
if (disks is null || disks.All(d => d.HealthPercent is null)) unavailable.Add(DiskComponent);
if (snapshot is null)
{
unavailable.Add(MemoryComponent);
unavailable.Add(UptimeComponent);
}

return unavailable;
}

/// <summary>Component names used in <see cref="HealthScoreResult.UnavailableComponents"/>.</summary>
internal const string DiskComponent = "Disk";
internal const string MemoryComponent = "Memory";
Expand Down
6 changes: 3 additions & 3 deletions SysManager/SysManager/SysManager.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
<RootNamespace>SysManager</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<NoWarn>NU1603;NU1701</NoWarn>
<Version>1.76.10</Version>
<FileVersion>1.76.10.0</FileVersion>
<AssemblyVersion>1.76.10.0</AssemblyVersion>
<Version>1.76.11</Version>
<FileVersion>1.76.11.0</FileVersion>
<AssemblyVersion>1.76.11.0</AssemblyVersion>
<Product>SysManager</Product>
<Description>SysManager — Windows system monitoring toolkit by laurentiu021. Network, updates, health, logs, safe deep cleanup.</Description>
<PackageProjectUrl>https://github.com/laurentiu021/SystemManager</PackageProjectUrl>
Expand Down