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