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

## [Unreleased]

## [0.48.26] - 2026-05-15

### Changed
- **SystemInfoService** — static WMI data (OS caption, CPU name, disk models)
is now cached on first query; only dynamic data (CPU load, RAM, uptime) is
re-queried every 60 seconds, reducing WMI overhead by ~70% (PERF-M1).
- **NetworkSharedState** — `RecomputeStats` rewritten with manual loops instead
of LINQ `.Where().Select().ToList()`, eliminating heap allocations on the
hot path that runs 32×/sec per target (PERF-M2).
- **TrayIconService** — added `Interlocked` re-entrancy guard on
`UpdateTooltipAsync` so overlapping timer ticks skip instead of stacking
concurrent WMI calls (PERF-M4).

## [0.48.25] - 2026-05-15

### Fixed
Expand Down
73 changes: 66 additions & 7 deletions SysManager/SysManager/Services/SystemInfoService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,47 @@

/// <summary>
/// Collects system information via WMI / CIM (no PowerShell spawn needed).
/// PERF-M1: Static data (OS caption, CPU name, disk models) is cached on first
/// query and reused — only dynamic data (CPU load, RAM, uptime) is refreshed.
/// </summary>
public sealed class SystemInfoService
{
// Cached static data — queried once, never changes during app lifetime.
private OsInfo? _cachedOs;
private CpuInfo? _cachedCpuStatic;
private List<DiskInfo>? _cachedDisks;
Comment on lines +18 to +20

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== SystemSnapshot definition =="
fd -i '^SystemSnapshot\.cs$' | while read -r f; do
  echo "--- $f ---"
  sed -n '1,220p' "$f"
done

echo
echo "== Disk collection usages =="
rg -n -C2 '\bDisks\b|\bSystemSnapshot\s*\(' --glob '!**/bin/**' --glob '!**/obj/**'

Repository: laurentiu021/SystemManager

Length of output: 12110


🏁 Script executed:

cat -n SysManager/SysManager/Services/SystemInfoService.cs

Repository: laurentiu021/SystemManager

Length of output: 11614


Defensive copy required: _cachedDisks is returned without cloning.

Lines 44–45 directly reference the cached list without creating a defensive copy. Unlike OsInfo and CpuInfo (which use record with for cloning), List<DiskInfo> is mutable. If a consumer mutates snap.Disks, the cache is corrupted for subsequent snapshots.

Change line 45 to:

disks = new List<DiskInfo>(_cachedDisks);

Alternatively, update SystemSnapshot.Disks to use IReadOnlyList<DiskInfo> to enforce immutability at the type level.

🤖 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/SystemInfoService.cs` around lines 18 - 20,
The cached mutable list _cachedDisks is returned directly which allows callers
to mutate the cache; update the code that builds SystemSnapshot (the place using
_cachedDisks to set SystemSnapshot.Disks) to return a defensive copy (e.g., new
List<DiskInfo>(_cachedDisks)) or change the SystemSnapshot.Disks type to
IReadOnlyList<DiskInfo> and construct an immutable copy; ensure this mirrors how
OsInfo and CpuInfo are cloned via record with so the cache cannot be mutated by
consumers.

private readonly object _cacheLock = new();

public Task<SystemSnapshot> CaptureAsync(CancellationToken ct = default)
=> Task.Run(() => Capture(), ct);

private SystemSnapshot Capture()
{
var os = QueryOs();
var cpu = QueryCpu();
OsInfo os;
CpuInfo cpu;
List<DiskInfo> disks;

lock (_cacheLock)
{
// Static OS info (caption, version, build, arch) — cache it.
// Uptime is dynamic, so we always refresh it.
_cachedOs ??= QueryOsStatic();
os = _cachedOs with { Uptime = QueryUptime() };

// CPU name/cores/threads/clock are static; only LoadPercentage is dynamic.
_cachedCpuStatic ??= QueryCpuStatic();
cpu = _cachedCpuStatic with { LoadPercent = QueryCpuLoad() };

// Disk info is static (models don't change at runtime).
_cachedDisks ??= QueryDisks();
disks = _cachedDisks;
}

var mem = QueryMemory();
var disks = QueryDisks();
return new SystemSnapshot(os, cpu, mem, disks, DateTime.Now);
}

private static OsInfo QueryOs()
private static OsInfo QueryOsStatic()
{
using var searcher = new ManagementObjectSearcher("SELECT Caption,Version,BuildNumber,OSArchitecture,LastBootUpTime FROM Win32_OperatingSystem");
using var osCollection = searcher.Get();
Expand All @@ -48,9 +73,29 @@
return new OsInfo("Windows", "", "", TimeSpan.Zero, "");
}

private static CpuInfo QueryCpu()
private static TimeSpan QueryUptime()
{
using var searcher = new ManagementObjectSearcher("SELECT Name,NumberOfCores,NumberOfLogicalProcessors,MaxClockSpeed,LoadPercentage FROM Win32_Processor");
using var searcher = new ManagementObjectSearcher("SELECT LastBootUpTime FROM Win32_OperatingSystem");
using var osCollection = searcher.Get();
foreach (ManagementObject mo in osCollection)
{
using (mo)
{
var lastBootRaw = mo["LastBootUpTime"]?.ToString();
if (!string.IsNullOrEmpty(lastBootRaw))
{
try { return DateTime.Now - ManagementDateTimeConverter.ToDateTime(lastBootRaw); }
catch (FormatException) { }

Check notice

Code scanning / CodeQL

Poor error handling: empty catch block Note

Poor error handling: empty catch block.
catch (InvalidCastException) { }

Check notice

Code scanning / CodeQL

Poor error handling: empty catch block Note

Poor error handling: empty catch block.
}
}
}
return TimeSpan.Zero;
}

private static CpuInfo QueryCpuStatic()
{
using var searcher = new ManagementObjectSearcher("SELECT Name,NumberOfCores,NumberOfLogicalProcessors,MaxClockSpeed FROM Win32_Processor");
using var cpuCollection = searcher.Get();
foreach (ManagementObject mo in cpuCollection)
{
Expand All @@ -61,12 +106,26 @@
Convert.ToUInt32(mo["NumberOfCores"] ?? 0u),
Convert.ToUInt32(mo["NumberOfLogicalProcessors"] ?? 0u),
Convert.ToUInt32(mo["MaxClockSpeed"] ?? 0u),
Convert.ToDouble(mo["LoadPercentage"] ?? 0.0));
0);
}
}
return new CpuInfo("Unknown", 0, 0, 0, 0);
}

private static double QueryCpuLoad()
{
using var searcher = new ManagementObjectSearcher("SELECT LoadPercentage FROM Win32_Processor");
using var cpuCollection = searcher.Get();
foreach (ManagementObject mo in cpuCollection)
{
using (mo)
{
return Convert.ToDouble(mo["LoadPercentage"] ?? 0.0);
}
}
return 0;
Comment on lines +115 to +126

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 | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

For WMI Win32_Processor, does LoadPercentage return one instance per physical processor package, and should a system-wide CPU usage value aggregate all returned instances?

💡 Result:

The WMI class Win32_Processor returns one instance per physical processor package (socket) [1][2][3]. The LoadPercentage property represents the load capacity of that specific physical processor, averaged over the last second [4][2]. Regarding system-wide CPU usage, you should not simply aggregate or average the LoadPercentage values from all Win32_Processor instances to determine total system CPU usage [5]. This approach is often inaccurate because Win32_Processor does not reliably report load across all modern multi-core or multi-threaded architectures [5]. For accurate system-wide CPU usage, it is recommended to use performance counter classes, such as Win32_PerfFormattedData_Counters_ProcessorInformation (specifically the _Total instance) or the Win32_PerfFormattedData_PerfOS_Processor class [1][6][5]. These classes are designed to provide aggregated performance data that correctly reflects the total system load [1][6].

Citations:


Consider using performance counters instead of Win32_Processor for system-wide CPU load.

Win32_Processor returns one instance per physical processor package, so this implementation reads only the first socket and misses data on multi-socket systems. However, aggregating LoadPercentage across Win32_Processor instances is not recommended—this approach is unreliable on modern multi-core architectures. Use Win32_PerfFormattedData_Counters_ProcessorInformation with the _Total instance instead, which provides accurate system-wide CPU load.

🤖 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/SystemInfoService.cs` around lines 115 - 126,
QueryCpuLoad currently uses ManagementObjectSearcher("SELECT LoadPercentage FROM
Win32_Processor") and returns the first Win32_Processor instance, which misses
multi-socket/system-wide load; replace the WMI query to read from
Win32_PerfFormattedData_Counters_ProcessorInformation (select
PercentProcessorTime where Name = "_Total" or use the _Total instance) and parse
that value to a double, keeping the same method signature, disposing the
searcher/collection as before (referencing QueryCpuLoad,
ManagementObjectSearcher, and ManagementObjectCollection) and return 0 on
missing/null values.

}

private static MemoryInfo QueryMemory()
{
double totalKb = 0, freeKb = 0;
Expand Down
8 changes: 8 additions & 0 deletions SysManager/SysManager/Services/TrayIconService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public sealed class TrayIconService : IDisposable
private readonly DispatcherTimer _timer;
private TaskbarIcon? _trayIcon;
private bool _disposed;
private int _updating; // PERF-M4: re-entrancy guard for WMI calls

// Notification cooldowns — don't spam the user
private DateTime _lastRamNotification = DateTime.MinValue;
Expand Down Expand Up @@ -137,6 +138,9 @@ private async void OnTimerTick(object? sender, EventArgs e)

private async Task UpdateTooltipAsync()
{
// PERF-M4: Skip if a previous update is still running (WMI can be slow).
if (Interlocked.CompareExchange(ref _updating, 1, 0) != 0)
return;
try
{
var snapshot = await _sysInfo.CaptureAsync();
Expand All @@ -162,6 +166,10 @@ private async Task UpdateTooltipAsync()
{
Log.Warning("TrayIcon tooltip update failed: {Error}", ex.Message);
}
finally
{
Interlocked.Exchange(ref _updating, 0);
}
}

internal void CheckAndNotify(SystemSnapshot snapshot)
Expand Down
25 changes: 20 additions & 5 deletions SysManager/SysManager/ViewModels/NetworkSharedState.cs
Original file line number Diff line number Diff line change
Expand Up @@ -358,9 +358,20 @@ private void RecomputeStats(PingTarget target, ObservableCollection<DateTimePoin
var idx = Targets.IndexOf(target);
var offset = ((idx % 8) - 3.5) * 0.25;

var values = buffer.Where(p => p.Value.HasValue).Select(p => p.Value!.Value - offset).ToList();
var successful = values.Count;
var sum = values.Sum();
// PERF-M2: Avoid LINQ allocations (this runs 32x/sec per target).
// Single pass over buffer to compute sum and count.
int successful = 0;
double sum = 0;
for (int i = 0; i < buffer.Count; i++)
{
if (buffer[i].Value.HasValue)
{
sum += buffer[i].Value!.Value - offset;
successful++;
}
}

// Collect recent samples for jitter (walk backwards, no allocation beyond the list).
var recent = new List<double>(JitterSampleWindow);
for (int i = buffer.Count - 1; i >= 0 && recent.Count < JitterSampleWindow; i--)
{
Expand All @@ -372,8 +383,12 @@ private void RecomputeStats(PingTarget target, ObservableCollection<DateTimePoin

if (recent.Count >= 2)
{
var mean = recent.Average();
var variance = recent.Sum(v => (v - mean) * (v - mean)) / recent.Count;
double mean = 0;
for (int i = 0; i < recent.Count; i++) mean += recent[i];
mean /= recent.Count;
double variance = 0;
for (int i = 0; i < recent.Count; i++) variance += (recent[i] - mean) * (recent[i] - mean);
variance /= recent.Count;
target.JitterMs = Math.Round(Math.Sqrt(variance), 1);
}
else
Expand Down
Loading