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
4 changes: 3 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,9 @@ Key services:
every 10s as append-only NDJSON in `%LocalAppData%\SysManager\resource-history.ndjson`,
with 7/14/30-day retention (periodic prune). Reuses `SystemInfoService` + NvAPIWrapper +
`TemperatureService`; serialize/parse/prune/downsample/CSV are pure, unit-tested static
helpers. Strictly local — no system writes, nothing leaves the machine.
helpers, and the directory is injectable — like `BandwidthHistoryService` — so tests cover the
load and retention paths without touching the user's own history. Strictly local — no system
writes, nothing leaves the machine.
- Bandwidth Monitor sources — `IBandwidthMonitorService` is the seam with two implementations:
`ConnectionBandwidthSource` (default, no admin) sums `NetworkInterface` byte counters for total
throughput and reads the extended TCP/UDP tables via iphlpapi P/Invoke (`GetExtendedTcpTable`/
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to this project are documented here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.57.4] - 2026-08-06

### Fixed
- **Switching the Resource History chart between ranges no longer risks an error.** Picking a different period while another one was still loading could have both of them rebuild the five graphs at the same time, which could fail instead of drawing. Only one rebuild runs at a time now. This is the same problem that was fixed on the Bandwidth Monitor in 1.57.3 — Resource History had it too, and it was missed then.
- **An unreadable history file no longer crashes the recorder.** Both the Resource History and Bandwidth Monitor recorders handled a disk error while reading or writing their history, but not a permission error — and a locked-down or read-only file produces the second, not the first. Because the recording runs quietly in the background, that surfaced as the app failing over something the user never started. Both now log it and carry on with an empty chart, exactly as they already did for a disk error.

## [1.57.3] - 2026-08-06

### Fixed
Expand Down
185 changes: 185 additions & 0 deletions SysManager/SysManager.Tests/ResourceHistoryServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
// Author: laurentiu021 · https://github.com/laurentiu021/SystemManager
// License: MIT

using System;
using System.IO;
using System.Linq;
using System.Security.AccessControl;
using System.Security.Principal;
using System.Threading.Tasks;
using SysManager.Models;
using SysManager.Services;

Expand Down Expand Up @@ -212,3 +218,182 @@ public void ToCsv_UsesInvariantDecimalSeparator()
public void RetentionOptions_AreSevenFourteenThirty()
=> Assert.Equal([7, 14, 30], ResourceHistoryService.RetentionOptions);
}

/// <summary>
/// Disk-backed tests for <see cref="ResourceHistoryService"/>, using the injected temp directory
/// so the developer's own history in %LOCALAPPDATA% is never read or written.
/// <para>These could not exist before: the service pinned its paths to
/// <see cref="Environment.SpecialFolder.LocalApplicationData"/> in <c>static readonly</c> fields, and
/// that resolves through the Win32 known-folder API — it ignores the <c>LOCALAPPDATA</c> environment
/// variable, so not even a child process could redirect it. Every test above had to stay on the pure
/// helpers as a result, leaving the load/prune/retention paths uncovered.</para>
/// </summary>
public class ResourceHistoryServiceDiskTests : IDisposable
{
private readonly string _dir;

public ResourceHistoryServiceDiskTests()
{
_dir = Path.Combine(Path.GetTempPath(), "SysManagerResourceHistoryTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dir);
}

public void Dispose()
{
try { if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true); }
catch (IOException) { /* a leftover temp dir must never fail a test run */ }
GC.SuppressFinalize(this);
}

// skipHardwareInit: none of these tests read a sensor, and probing real hardware in a unit
// test would make it environment-dependent.
private ResourceHistoryService NewService() => new(
new SystemInfoService(),
new TemperatureService(new DiskHealthService(), skipHardwareInit: true),
_dir);

private string DataPath => Path.Combine(_dir, "resource-history.ndjson");

private void Seed(params ResourceSample[] samples)
=> File.WriteAllLines(DataPath, samples.Select(ResourceHistoryService.Serialize));

private static ResourceSample At(DateTime t, double cpu = 10)
=> new(t, cpu, 20, null, null, null);

// ── The seam itself ─────────────────────────────────────────────────────

[Fact]
public async Task LoadAsync_WithNoFile_ReturnsEmpty()
{
using var service = NewService();
Assert.Empty(await service.LoadAsync(TimeSpan.FromDays(7)));
}

[Fact]
public async Task LoadAsync_ReadsOnlyItsOwnDirectory()
{
var now = DateTime.Now;
Seed(At(now.AddMinutes(-1)));

using var mine = NewService();
Assert.Single(await mine.LoadAsync(TimeSpan.FromHours(1)));

// A different directory must be independently empty — proof the path is genuinely injected
// and not silently falling back to the shared profile location.
var other = Path.Combine(_dir, "other");
Directory.CreateDirectory(other);
using var elsewhere = new ResourceHistoryService(
new SystemInfoService(),
new TemperatureService(new DiskHealthService(), skipHardwareInit: true),
other);
Assert.Empty(await elsewhere.LoadAsync(TimeSpan.FromDays(30)));
}

// ── Load: range filtering and ordering ──────────────────────────────────

[Fact]
public async Task LoadAsync_ExcludesSamplesOlderThanTheRange()
{
var now = DateTime.Now;
Seed(
At(now.AddDays(-3)), // outside a 1-hour range
At(now.AddMinutes(-30)), // inside
At(now.AddMinutes(-5))); // inside

using var service = NewService();
var loaded = await service.LoadAsync(TimeSpan.FromHours(1));

Assert.Equal(2, loaded.Count);
}

[Fact]
public async Task LoadAsync_ReturnsOldestFirst()
{
// The file is append-only and time-ordered, and LoadAsync walks it backwards then reverses.
// Chart code depends on this order, so it is asserted rather than assumed.
var now = DateTime.Now;
Seed(At(now.AddMinutes(-30), cpu: 1), At(now.AddMinutes(-20), cpu: 2), At(now.AddMinutes(-10), cpu: 3));

using var service = NewService();
var loaded = await service.LoadAsync(TimeSpan.FromHours(1));

Assert.Equal([1d, 2d, 3d], loaded.Select(s => s.CpuPercent));
}

[Fact]
public async Task LoadAsync_SkipsMalformedLinesWithoutFailing()
{
var now = DateTime.Now;
File.WriteAllLines(DataPath,
[
"not json at all",
ResourceHistoryService.Serialize(At(now.AddMinutes(-10))),
"{ truncated",
]);

using var service = NewService();
var loaded = await service.LoadAsync(TimeSpan.FromHours(1));

Assert.Single(loaded);
}

[Fact]
public async Task LoadAsync_WhenTheFileCannotBeRead_ReturnsEmptyRatherThanThrowing()
{
// UnauthorizedAccessException is a SIBLING of IOException, not a subclass, so it escaped the
// service's original single `catch (IOException)`. That matters because the history file is
// read by a background sampler the user never invoked: an unhandled throw there is a crash
// with no action that caused it. A deny-read ACL is what actually produces it — a directory
// in the file's place does not, because File.Exists returns false and the guard short-circuits.
Seed(At(DateTime.Now.AddMinutes(-1)));

var identity = WindowsIdentity.GetCurrent().User;
if (identity is null) return; // no SID to deny — nothing to assert on this host

var info = new FileInfo(DataPath);
var acl = info.GetAccessControl();
var deny = new FileSystemAccessRule(identity, FileSystemRights.Read, AccessControlType.Deny);
acl.AddAccessRule(deny);
info.SetAccessControl(acl);
try
{
using var service = NewService();
Assert.Empty(await service.LoadAsync(TimeSpan.FromDays(7)));
}
finally
{
// Remove the deny rule, or Dispose cannot delete the temp directory.
acl.RemoveAccessRule(deny);
info.SetAccessControl(acl);
}
}

// ── Retention persistence ───────────────────────────────────────────────

[Fact]
public void RetentionDays_DefaultsToSeven()
{
using var service = NewService();
Assert.Equal(7, service.RetentionDays);
}

[Fact]
public void RetentionDays_PersistsAcrossInstances()
{
using (var first = NewService())
first.RetentionDays = 30;

// A second instance, as after an app restart — the point of persisting at all.
using var second = NewService();
Assert.Equal(30, second.RetentionDays);
Assert.True(File.Exists(Path.Combine(_dir, "resource-history-config.json")));
}

[Fact]
public void RetentionDays_RejectsAValueOutsideTheOfferedOptions()
{
using var service = NewService();
service.RetentionDays = 999;
Assert.Equal(7, service.RetentionDays);
}
}
115 changes: 115 additions & 0 deletions SysManager/SysManager.Tests/ResourceHistoryViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
// Author: laurentiu021 · https://github.com/laurentiu021/SystemManager
// License: MIT

using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using SysManager.Models;
using SysManager.Services;
using SysManager.ViewModels;

namespace SysManager.Tests;
Expand Down Expand Up @@ -42,3 +47,113 @@ public void BuildSummary_WithTemps_IncludesPeakTemp()
Assert.Contains("CPU temp peak 72°C", summary);
}
}

/// <summary>
/// Tests that drive the real reload path, which needs a <see cref="ResourceHistoryService"/> pointed
/// at a temp directory — only possible since that service gained the injectable configDir seam.
/// </summary>
public class ResourceHistoryViewModelReloadTests : IDisposable
{
private readonly string _dir;

public ResourceHistoryViewModelReloadTests()
{
_dir = Path.Combine(Path.GetTempPath(), "SysManagerResourceHistoryVmTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dir);
}

public void Dispose()
{
try { if (Directory.Exists(_dir)) Directory.Delete(_dir, recursive: true); }
catch (IOException) { /* a leftover temp dir must never fail a test run */ }
GC.SuppressFinalize(this);
}

private ResourceHistoryService SeededService(params ResourceSample[] samples)
{
File.WriteAllLines(
Path.Combine(_dir, "resource-history.ndjson"),
samples.Select(ResourceHistoryService.Serialize));
return new ResourceHistoryService(
new SystemInfoService(),
// skipHardwareInit: the reload path reads no sensor; probing real hardware would make
// this test depend on the machine it runs on.
new TemperatureService(new DiskHealthService(), skipHardwareInit: true),
_dir);
}

[Fact]
public async Task ConcurrentReloadsDoNotCorruptTheChartSeries()
{
// Regression pin for the same defect CI proved on BandwidthMonitorViewModel, which this VM
// still carried: THREE entry points can run ReloadAsync at once — the constructor's
// InitializeAsync, the fire-and-forget in OnSelectedRangeChanged, and the Refresh command —
// and each calls ReplaceWith on five buffers LiveCharts observes. Its CollectionDeepObserver
// updates a HashSet from the change notification, so a second thread arriving mid-notification
// corrupts it ("Operations that change non-concurrent collections must have exclusive access").
//
// WHAT THIS TEST CAN AND CANNOT PROVE: the corruption needs a RENDERED chart for LiveCharts to
// attach its observer, which never happens in a headless test — so this asserts the reachable
// invariant (the series stay coherent, nothing escapes) rather than reproducing the observer
// corruption. The gate is justified by the proven CI failure on the identical sibling.
var now = DateTime.Now;
using var service = SeededService(
new ResourceSample(now.AddMinutes(-30), 10, 20, 30, 40, 50),
new ResourceSample(now.AddMinutes(-20), 15, 25, 35, 45, 55),
new ResourceSample(now.AddMinutes(-10), 20, 30, 40, 50, 60));
using var vm = new ResourceHistoryViewModel(service);
await vm.InitializationComplete;

// Rapid range assignment starts a reload from the changed-handler each time, bypassing the
// command — that is what genuinely overlaps.
for (int i = 0; i < 20; i++)
vm.SelectedRange = vm.RangeOptions[i % vm.RangeOptions.Count];

// Fire the command mid-flight: the second, independent entry point.
var refresh = vm.ReloadCommand.ExecuteAsync(null);
for (int i = 0; i < 20; i++)
vm.SelectedRange = vm.RangeOptions[(i + 1) % vm.RangeOptions.Count];
await refresh;

// All three usage series are rebuilt from the same downsampled points, so their lengths must
// agree. A torn ReplaceWith is exactly what makes them diverge.
var lengths = vm.UsageSeries
.Select(s => ((System.Collections.IEnumerable)s.Values!).Cast<object>().Count())
.Distinct()
.ToList();
Assert.Single(lengths);
}

[Fact]
public async Task ARedundantRefreshOnTheSameRangeStillShowsThatRange()
{
// The gate drops nothing user-visible: a second reload for the already-selected range still
// ends on that range, with its samples counted, and releases the progress bar.
var now = DateTime.Now;
using var service = SeededService(
new ResourceSample(now.AddMinutes(-20), 10, 20, null, null, null),
new ResourceSample(now.AddMinutes(-10), 20, 30, null, null, null));
using var vm = new ResourceHistoryViewModel(service);
await vm.InitializationComplete;

vm.SelectedRange = vm.RangeOptions.First(r => r.Range == TimeSpan.FromHours(1));
await vm.ReloadCommand.ExecuteAsync(null);
await vm.ReloadCommand.ExecuteAsync(null); // a redundant Refresh on the same range

Assert.Equal(2, vm.SampleCount);
Assert.True(vm.HasData);
Assert.False(vm.IsBusy); // the gate released, so the progress bar cleared
}

[Fact]
public async Task ReloadWithNoHistory_ReportsTheEmptyStateRatherThanABlankChart()
{
using var service = SeededService(); // no samples at all
using var vm = new ResourceHistoryViewModel(service);
await vm.InitializationComplete;

Assert.False(vm.HasData);
Assert.Equal(0, vm.SampleCount);
Assert.Contains("No history yet", vm.StatusMessage);
}
}
7 changes: 7 additions & 0 deletions SysManager/SysManager/Services/BandwidthHistoryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ public async Task AppendAsync(BandwidthSample sample, CancellationToken ct = def
await File.AppendAllTextAsync(_dataPath, Serialize(sample) + "\n", ct).ConfigureAwait(false);
}
catch (IOException ex) { Log.Debug("Bandwidth history append failed: {Error}", ex.Message); }
// Same reason as ResourceHistoryService: UnauthorizedAccessException is a SIBLING of
// IOException, so it escapes the catch above. Here an escaping throw is worse — the
// caller sets _historyOwnsChart before awaiting, and only OperationCanceledException
// hands it back, so an unhandled access error would freeze the live chart for good.
catch (UnauthorizedAccessException ex) { Log.Debug("Bandwidth history append denied: {Error}", ex.Message); }
finally { _fileLock.Release(); }
}

Expand All @@ -77,6 +82,7 @@ public async Task<IReadOnlyList<BandwidthSample>> LoadAsync(TimeSpan range, Canc
return samples;
}
catch (IOException ex) { Log.Debug("Bandwidth history load failed: {Error}", ex.Message); return []; }
catch (UnauthorizedAccessException ex) { Log.Debug("Bandwidth history load denied: {Error}", ex.Message); return []; }
finally { _fileLock.Release(); }
}

Expand All @@ -97,6 +103,7 @@ public async Task PruneAsync(CancellationToken ct = default)
}
catch (OperationCanceledException) { /* shutdown */ }
catch (IOException ex) { Log.Debug("Bandwidth history prune failed: {Error}", ex.Message); }
catch (UnauthorizedAccessException ex) { Log.Debug("Bandwidth history prune denied: {Error}", ex.Message); }
finally { _fileLock.Release(); }
}

Expand Down
Loading
Loading