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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.48.25] - 2026-05-15

### Fixed
- **HealthAnalyzer** — no longer claims "DNS is clean" when DNS IS bad; when
both DNS and game server show trouble, correctly returns Mixed verdict
instead of GameServer (FUNC-M2).
- **TuneUpService** — empty directory removal now sorts by path depth (separator
count) instead of string length, ensuring deepest directories are deleted
first regardless of path name length (FUNC-M3).
- **SpeedTestHistoryService** — `SaveAsync` and `ClearAsync` now serialize via
`SemaphoreSlim` to prevent concurrent load-modify-save races that could lose
history entries (FUNC-M4).
- **FixedDriveService** — multi-disk enrichment now maps drive letters to
physical disks via `MSFT_Partition.DiskNumber`, correctly annotating media
type and bus type on systems with multiple drives (FUNC-M5).

## [0.48.24] - 2026-05-15

### Fixed
Expand Down
8 changes: 4 additions & 4 deletions SysManager/SysManager.Tests/HealthAnalyzerEdgeCaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ public void Analyze_StreamBadOnly_ReturnsStreamingService()
}

[Fact]
public void Analyze_DnsAndGameBad_ReturnsGameServer()
public void Analyze_DnsAndGameBad_ReturnsMixed()
{
var metrics = new[]
{
Expand All @@ -142,9 +142,9 @@ public void Analyze_DnsAndGameBad_ReturnsGameServer()
new TargetMetric("game", TargetRole.GameServer, 80.0, JitterWarnMs + 5, 0, 10),
};
var result = HealthAnalyzer.Analyze(metrics);
// When dns AND game are bad, it doesn't match "dnsBad && !gameBad && !streamBad"
// so falls through to gameBad check
Assert.Equal(HealthVerdict.GameServer, result.Verdict);
// FUNC-M2: When dns AND game are bad, return Mixed — not GameServer,
// because saying "DNS is clean" would be incorrect.
Assert.Equal(HealthVerdict.Mixed, result.Verdict);
}

[Fact]
Expand Down
8 changes: 3 additions & 5 deletions SysManager/SysManager.Tests/HealthAnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,9 @@ public void DnsAndGameBad_ReturnsMixed()
M("dns", TargetRole.PublicDns, loss: 5),
M("game", TargetRole.GameServer, loss: 5),
});
// dns bad + game bad → not pure ISP, not pure GameServer → Mixed
// Actually per the code: dnsBad && !gameBad → ISP. But here gameBad is true too.
// The code checks: if (dnsBad && !gameBad && !streamBad) → ISP. Fails.
// Then: if (gameBad) → GameServer. This wins.
Assert.Equal(HealthVerdict.GameServer, d.Verdict);
// FUNC-M2: dns bad + game bad → Mixed (not GameServer, because DNS
// is NOT clean — claiming "it's the game server, not you" would be wrong).
Assert.Equal(HealthVerdict.Mixed, d.Verdict);
}

[Fact]
Expand Down
38 changes: 33 additions & 5 deletions SysManager/SysManager/Services/FixedDriveService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,41 @@ public static IReadOnlyList<FixedDrive> Enumerate()
}
}

// We can't easily map DeviceId -> drive letter without another join,
// so if we have exactly one disk we annotate everything with it.
if (media.Count == 1)
// FUNC-M5: Map drive letters to physical disks via MSFT_Partition.
// This works for multi-disk systems (not just single-disk).
var letterToDisk = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
var (m, b) = media.Values.First();
for (var i = 0; i < drives.Count; i++)
using var partSearch = new ManagementObjectSearcher(scope,
new ObjectQuery("SELECT DriveLetter, DiskNumber FROM MSFT_Partition WHERE DriveLetter IS NOT NULL"));
using var partCollection = partSearch.Get();
foreach (ManagementObject mo in partCollection)
{
using (mo)
{
var letter = mo["DriveLetter"]?.ToString();
var diskNum = mo["DiskNumber"]?.ToString();
if (!string.IsNullOrEmpty(letter) && !string.IsNullOrEmpty(diskNum))
letterToDisk[letter + ":"] = diskNum;
}
}
}
catch (ManagementException) { /* partition query not supported — fall through */ }

for (var i = 0; i < drives.Count; i++)
{
var driveLetter = drives[i].Letter.TrimEnd('\\', '/');
if (letterToDisk.TryGetValue(driveLetter, out var diskId) &&
media.TryGetValue(diskId, out var info))
{
drives[i] = drives[i] with { MediaType = info.Media, BusType = info.Bus };
}
else if (media.Count == 1)
{
// Fallback: single disk — annotate all drives with it.
var (m, b) = media.Values.First();
drives[i] = drives[i] with { MediaType = m, BusType = b };
}
}
}
catch (ManagementException)
Expand Down
11 changes: 10 additions & 1 deletion SysManager/SysManager/Services/HealthAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public static HealthDiagnostic Analyze(IEnumerable<TargetMetric> metrics)
return diag;
}

if (gameBad)
if (gameBad && !dnsBad)
{
diag.Verdict = HealthVerdict.GameServer;
diag.Headline = "It's the game server, not you";
Expand All @@ -99,6 +99,15 @@ public static HealthDiagnostic Analyze(IEnumerable<TargetMetric> metrics)
return diag;
}

if (gameBad && dnsBad)
{
diag.Verdict = HealthVerdict.Mixed;
diag.Headline = "Multiple layers affected";
diag.Detail = "Both DNS and game server are showing trouble — likely an ISP or routing issue affecting multiple endpoints.";
diag.ColorHex = "#FF6B6B";
return diag;
}

if (streamBad && !dnsBad)
{
diag.Verdict = HealthVerdict.StreamingService;
Expand Down
23 changes: 21 additions & 2 deletions SysManager/SysManager/Services/SpeedTestHistoryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,19 @@ public sealed class SpeedTestHistoryService
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

// FUNC-M4: Serialize all file operations to prevent concurrent SaveAsync
// calls from racing (load-modify-save is not atomic). A SemaphoreSlim(1,1)
// acts as an async-compatible mutex.
private readonly SemaphoreSlim _fileLock = new(1, 1);
Comment on lines +31 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "DI registrations or manual construction:"
rg -n -C2 '\b(AddSingleton|AddScoped|AddTransient)\s*<\s*SpeedTestHistoryService\b|new\s+SpeedTestHistoryService\s*\('

echo
echo "All references to SpeedTestHistoryService:"
rg -n -C2 '\bSpeedTestHistoryService\b'

Repository: laurentiu021/SystemManager

Length of output: 8857


The instance-level lock does not prevent cross-instance writes; make it static or use a shared coordinator.

The service is instantiated manually in SpeedTestViewModel (line 19: private readonly SpeedTestHistoryService _history = new();), not registered as a singleton. Each ViewModel instance therefore has its own _fileLock, so concurrent saves from different ViewModels still race. Consider a static lock, a shared file coordinator, or registering the service as singleton in DI.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/Services/SpeedTestHistoryService.cs` around lines 31 -
34, The instance-level SemaphoreSlim _fileLock inside SpeedTestHistoryService
doesn't prevent races across multiple SpeedTestHistoryService instances created
by SpeedTestViewModel; change the lock to a static SemaphoreSlim (e.g., make
_fileLock static readonly) so all instances share the same async mutex, or
alternatively change registration so SpeedTestHistoryService is a singleton in
DI or introduce a shared file coordinator used by SaveAsync/LoadAsync to
serialize file access; update references to _fileLock and ensure
SaveAsync/LoadAsync await the shared lock appropriately.


/// <summary>
/// Loads all saved results from disk. Returns empty list on any error.
/// </summary>
public async Task<List<SpeedTestResult>> LoadAsync(CancellationToken ct = default)
=> await LoadCoreAsync(ct).ConfigureAwait(false);

/// <summary>Internal load without locking — called from within locked sections.</summary>
private async Task<List<SpeedTestResult>> LoadCoreAsync(CancellationToken ct)
Comment on lines 39 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Lock LoadAsync too.

Line 39 still lets public reads bypass _fileLock, so a concurrent SaveAsync/ClearAsync can overlap with File.WriteAllTextAsync/File.Delete. In that window, LoadAsync can return an empty list from a transient JsonException/IOException, which makes history briefly disappear for callers even though the write succeeds.

Suggested fix
 public async Task<List<SpeedTestResult>> LoadAsync(CancellationToken ct = default)
-    => await LoadCoreAsync(ct).ConfigureAwait(false);
+{
+    await _fileLock.WaitAsync(ct).ConfigureAwait(false);
+    try
+    {
+        return await LoadCoreAsync(ct).ConfigureAwait(false);
+    }
+    finally
+    {
+        _fileLock.Release();
+    }
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/Services/SpeedTestHistoryService.cs` around lines 39 -
43, Public LoadAsync currently calls LoadCoreAsync without acquiring _fileLock,
allowing reads to race with SaveAsync/ClearAsync; change LoadAsync to acquire
the same _fileLock used by SaveAsync and ClearAsync, await LoadCoreAsync while
holding the lock, and release the lock in a finally block so transient
JsonException/IOException during concurrent writes don't cause a temporary empty
history to be returned; reference LoadAsync, LoadCoreAsync, _fileLock,
SaveAsync, ClearAsync, File.WriteAllTextAsync and File.Delete when locating
where to add the lock.

{
try
{
Expand Down Expand Up @@ -68,9 +77,10 @@ public async Task<List<SpeedTestResult>> LoadAsync(CancellationToken ct = defaul
/// </summary>
public async Task SaveAsync(SpeedTestResult result, CancellationToken ct = default)
{
await _fileLock.WaitAsync(ct).ConfigureAwait(false);
try
{
var all = await LoadAsync(ct).ConfigureAwait(false);
var all = await LoadCoreAsync(ct).ConfigureAwait(false);
all.Add(result);

// Trim per engine: keep only the most recent MaxPerEngine entries.
Expand Down Expand Up @@ -104,13 +114,18 @@ public async Task SaveAsync(SpeedTestResult result, CancellationToken ct = defau
{
Log.Warning(ex, "Access denied saving speed test history");
}
finally
{
_fileLock.Release();
}
}

/// <summary>
/// Clears history for a specific engine, or all history if engine is null.
/// </summary>
public async Task ClearAsync(string? engine = null, CancellationToken ct = default)
{
await _fileLock.WaitAsync(ct).ConfigureAwait(false);
try
{
if (engine == null)
Expand All @@ -120,7 +135,7 @@ public async Task ClearAsync(string? engine = null, CancellationToken ct = defau
return;
}

var all = await LoadAsync(ct).ConfigureAwait(false);
var all = await LoadCoreAsync(ct).ConfigureAwait(false);
var filtered = all.Where(r => !string.Equals(r.Engine, engine, StringComparison.OrdinalIgnoreCase)).ToList();

if (filtered.Count == 0)
Expand Down Expand Up @@ -151,6 +166,10 @@ public async Task ClearAsync(string? engine = null, CancellationToken ct = defau
{
Log.Warning(ex, "Access denied clearing speed test history");
}
finally
{
_fileLock.Release();
}
}

/// <summary>JSON-serializable DTO for history entries.</summary>
Expand Down
5 changes: 4 additions & 1 deletion SysManager/SysManager/Services/TuneUpService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,11 @@ private static (long BytesFreed, int FilesDeleted, int Errors) CleanTempFiles(Ca
}

// Try to remove empty subdirectories
// FUNC-M3: Sort by directory depth (separator count) descending,
// not string length. A path like "C:\a\b\c" is deeper than
// "C:\longname" despite being shorter — we must delete deepest first.
foreach (var sub in Directory.EnumerateDirectories(dir, "*", SearchOption.AllDirectories)
.OrderByDescending(d => d.Length))
.OrderByDescending(d => d.Count(c => c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar)))
{
ct.ThrowIfCancellationRequested();
try
Expand Down
Loading