Fix unbounded memory growth during monitoring

While the app was running, working set grew ~20 MB/min (reaching tens of GB
over a day). dotnet-gcdump traced it to COM-callable wrappers accumulating from
the per-tick binding interop: SensorTreeItemViewModel.RefreshValues raised
PropertyChanged for Value/Min/Max/ToolTip on every sensor every update tick, and
ViewModelBase allocated a new PropertyChangedEventArgs per raise. WinUI's binding
engine is native, so each raised event marshals its args across the boundary and
retains a wrapper.

- ViewModelBase: reuse one cached PropertyChangedEventArgs per property name.
- SensorTreeItemViewModel.RefreshValues: raise PropertyChanged only when the
  formatted text actually changed.

Also close a leaked D3DKMT adapter handle: D3DDisplayDevice.GetDeviceInfoByIdentifier
opened the adapter but skipped CloseAdapter on every early-return failure path,
and it runs on each GPU's Update() tick. Close it in a finally block.

Verified with dotnet-gcdump over an 18-minute soak: managed heap and live object
count stay flat (~50 MB) instead of climbing 47 -> 470 MB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 16:16:40 -05:00
co-authored by Claude Opus 4.8
parent 041e21f9a6
commit 1bde5d531d
3 changed files with 119 additions and 66 deletions
@@ -2,6 +2,7 @@
// If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
// Copyright (C) LibreHardwareMonitor and Contributors.
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
@@ -10,6 +11,12 @@ namespace LibreHardwareMonitor.Windows.WinUI.ViewModels;
public abstract class ViewModelBase : INotifyPropertyChanged
{
// Reuse one PropertyChangedEventArgs per property name. WinUI's binding engine handles PropertyChanged natively, so
// each raised event marshals its args across the managed/native boundary and creates a COM-callable wrapper that the
// interop layer retains. Allocating a fresh PropertyChangedEventArgs on every raise — RefreshValues fires four per
// sensor on every update tick — therefore leaked a wrapper per raise (managed-heap growth ~28 MB/min → multi-GB).
private static readonly ConcurrentDictionary<string, PropertyChangedEventArgs> EventArgsByName = new();
public event PropertyChangedEventHandler? PropertyChanged;
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
@@ -24,6 +31,11 @@ public abstract class ViewModelBase : INotifyPropertyChanged
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
PropertyChangedEventHandler? handler = PropertyChanged;
if (handler == null)
return;
PropertyChangedEventArgs args = EventArgsByName.GetOrAdd(propertyName ?? string.Empty, static name => new PropertyChangedEventArgs(name));
handler(this, args);
}
}