diff --git a/CHANGELOG.md b/CHANGELOG.md index d8d64297..2a7c6269 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.48.27] - 2026-05-15 + +### Fixed +- **NetworkSharedState** — SkiaSharp `SolidColorPaint` objects are now disposed + when a ping target is removed, preventing unmanaged memory leaks (CQ-M1). +- **NetworkSharedState** — latency chart offset now uses a stable hash of the + target host instead of `Targets.IndexOf`, preventing visual jumps when + targets are removed mid-session (CQ-M2). +- **PerformanceViewModel** — added `Dispose` override to clean up snapshot + reference and satisfy the base class disposal contract (CQ-M4). + ## [0.48.26] - 2026-05-15 ### Changed diff --git a/SysManager/SysManager/ViewModels/NetworkSharedState.cs b/SysManager/SysManager/ViewModels/NetworkSharedState.cs index 15bf4cf6..23d1452b 100644 --- a/SysManager/SysManager/ViewModels/NetworkSharedState.cs +++ b/SysManager/SysManager/ViewModels/NetworkSharedState.cs @@ -225,10 +225,23 @@ internal void RemoveTargetInternal(PingTarget target) _targetHandlers.Remove(target.Host); } Targets.Remove(target); + + // CQ-M1: Dispose SkiaSharp paint objects attached to the series being removed. + // Without this, SolidColorPaint instances (and their unmanaged SKPaint handles) + // leak every time a target is removed. var idx = LatencySeries.ToList().FindIndex(s => s.Name?.Contains($"({target.Host})") == true); - if (idx >= 0) LatencySeries.RemoveAt(idx); + if (idx >= 0) + { + DisposeSeries(LatencySeries[idx]); + LatencySeries.RemoveAt(idx); + } var tIdx = TraceSeries.ToList().FindIndex(s => s.Name?.Contains($"({target.Host})") == true); - if (tIdx >= 0) TraceSeries.RemoveAt(tIdx); + if (tIdx >= 0) + { + DisposeSeries(TraceSeries[tIdx]); + TraceSeries.RemoveAt(tIdx); + } + Buffers.TryRemove(target.Host, out _); TraceBuffers.TryRemove(target.Host, out _); LatestRoutes.Remove(target.Host); @@ -237,6 +250,18 @@ internal void RemoveTargetInternal(PingTarget target) RefreshHopTable(); } + /// Disposes paint resources attached to a chart series. + private static void DisposeSeries(ISeries series) + { + if (series is LineSeries line) + { + (line.Stroke as IDisposable)?.Dispose(); + (line.GeometryStroke as IDisposable)?.Dispose(); + (line.GeometryFill as IDisposable)?.Dispose(); + (line.Fill as IDisposable)?.Dispose(); + } + } + public void ClearHistory() { foreach (var buf in Buffers.Values) buf.Clear(); @@ -313,8 +338,9 @@ internal void FlushPending() double? shown = sample.LatencyMs; if (shown.HasValue) { - var idx = Targets.IndexOf(target); - var offset = ((idx % 8) - 3.5) * 0.25; + // CQ-M2: Stable offset (same fix as RecomputeStats). + var stableIdx = Math.Abs(target.Host.GetHashCode()) % 8; + var offset = ((stableIdx % 8) - 3.5) * 0.25; shown = shown.Value + offset; } @@ -355,8 +381,11 @@ private void RecomputeStats(PingTarget target, ObservableCollection