From 1ba38eb1b77d3ec60f1462e9c34c6da0f9d29eb7 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 9 Jul 2026 14:38:25 -0400 Subject: [PATCH 1/8] Downscale package icons and bound the icon cache --- .../Models/PackageCollections.cs | 108 ++++++++++++++++-- .../SettingsPages/Interface_PViewModel.cs | 1 + 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/src/UniGetUI.Avalonia/Models/PackageCollections.cs b/src/UniGetUI.Avalonia/Models/PackageCollections.cs index b008c68bc..4e8adfdce 100644 --- a/src/UniGetUI.Avalonia/Models/PackageCollections.cs +++ b/src/UniGetUI.Avalonia/Models/PackageCollections.cs @@ -1,7 +1,7 @@ -using System.Collections.Concurrent; using System.Collections.ObjectModel; using System.ComponentModel; using System.Net.Http; +using Avalonia; using Avalonia.Collections; using Avalonia.Media.Imaging; using Avalonia.Threading; @@ -27,9 +27,71 @@ public sealed class PackageWrapper : INotifyPropertyChanged, IDisposable { Timeout = TimeSpan.FromSeconds(8), }; - private static readonly ConcurrentDictionary _iconCache = new(); private static readonly SemaphoreSlim _iconLoadSemaphore = new(4, 4); + // List icons render at 24-64px; decoding/caching them at native resolution (often 256-512px) + // wastes ~10-60x the memory. Cap the cached side to cover 64px at 2x DPI. + private const int MaxIconSide = 128; + + // Bounded LRU of decoded icons keyed by package hash. Was previously an unbounded static + // dictionary that kept every icon ever shown resident for the whole session. Entries are not + // disposed on eviction: the same Bitmap may still be bound to a visible row, and any wrapper + // still referencing it keeps it alive; dropping it here only makes it eligible for GC. + private const int MaxIconCacheEntries = 512; + private static readonly object _iconCacheLock = new(); + private static readonly Dictionary> _iconCache = new(); + private static readonly LinkedList<(long Hash, Bitmap? Bitmap)> _iconCacheOrder = new(); + + private static bool TryGetCachedIcon(long hash, out Bitmap? bitmap) + { + lock (_iconCacheLock) + { + if (_iconCache.TryGetValue(hash, out var node)) + { + _iconCacheOrder.Remove(node); + _iconCacheOrder.AddFirst(node); + bitmap = node.Value.Bitmap; + return true; + } + } + bitmap = null; + return false; + } + + private static void CacheIcon(long hash, Bitmap? bitmap) + { + lock (_iconCacheLock) + { + if (_iconCache.TryGetValue(hash, out var existing)) + { + existing.Value = (hash, bitmap); + _iconCacheOrder.Remove(existing); + _iconCacheOrder.AddFirst(existing); + return; + } + + var node = new LinkedListNode<(long, Bitmap?)>((hash, bitmap)); + _iconCache[hash] = node; + _iconCacheOrder.AddFirst(node); + + while (_iconCache.Count > MaxIconCacheEntries && _iconCacheOrder.Last is { } last) + { + _iconCacheOrder.RemoveLast(); + _iconCache.Remove(last.Value.Hash); + } + } + } + + /// Drops all cached decoded icons. Bound bitmaps stay alive via their own references. + public static void ClearIconCache() + { + lock (_iconCacheLock) + { + _iconCache.Clear(); + _iconCacheOrder.Clear(); + } + } + public IPackage Package { get; } public PackageWrapper Self => this; public int Index { get; set; } @@ -184,7 +246,7 @@ await Dispatcher.UIThread.InvokeAsync(() => private async Task LoadIconAsync() { long hash = Package.GetHash(); - if (_iconCache.TryGetValue(hash, out Bitmap? cached)) + if (TryGetCachedIcon(hash, out Bitmap? cached)) { if (cached is not null) IconBitmap = cached; @@ -198,7 +260,7 @@ private async Task LoadIconAsync() try { var uri = await Task.Run(Package.GetIconUrlIfAny).ConfigureAwait(false); - if (uri is null) { _iconCache[hash] = null; return; } + if (uri is null) { CacheIcon(hash, null); return; } Bitmap? decoded; if (uri.IsFile) @@ -207,7 +269,7 @@ private async Task LoadIconAsync() { // Avalonia's Bitmap (Skia) can't decode SVG/AVIF/ICO/TIFF — the // icon cache may produce those. Reject upfront so we don't throw. - _iconCache[hash] = null; + CacheIcon(hash, null); return; } decoded = await Task.Run(() => TryDecodeIcon(uri.LocalPath)).ConfigureAwait(false); @@ -217,11 +279,11 @@ private async Task LoadIconAsync() var bytes = await _iconHttpClient.GetByteArrayAsync(uri).ConfigureAwait(false); decoded = TryDecodeIcon(bytes, uri.Host); } - else { _iconCache[hash] = null; return; } + else { CacheIcon(hash, null); return; } - if (decoded is null) { _iconCache[hash] = null; return; } + if (decoded is null) { CacheIcon(hash, null); return; } bitmap = decoded; - _iconCache[hash] = bitmap; + CacheIcon(hash, bitmap); } finally { @@ -230,7 +292,7 @@ private async Task LoadIconAsync() await Dispatcher.UIThread.InvokeAsync(() => IconBitmap = bitmap); } - catch { _iconCache[hash] = null; } + catch { CacheIcon(hash, null); } } // Icons come from a shared on-disk cache that can hold empty or partial entries after an @@ -239,11 +301,11 @@ private async Task LoadIconAsync() { var info = new FileInfo(filePath); if (!info.Exists || info.Length == 0) return null; - return TryDecodeIcon(() => new Bitmap(filePath), filePath); + return TryDecodeIcon(() => { using var fs = File.OpenRead(filePath); return DecodeDownscaled(fs); }, filePath); } private static Bitmap? TryDecodeIcon(byte[] bytes, string source) - => bytes.Length == 0 ? null : TryDecodeIcon(() => new Bitmap(new MemoryStream(bytes)), source); + => bytes.Length == 0 ? null : TryDecodeIcon(() => { using var ms = new MemoryStream(bytes); return DecodeDownscaled(ms); }, source); private static Bitmap? TryDecodeIcon(Func decode, string source) { @@ -251,6 +313,30 @@ private async Task LoadIconAsync() catch (Exception ex) { Logger.Debug($"Discarding undecodable icon '{source}': {ex.Message}"); return null; } } + // Decodes at native resolution, then downscales to MaxIconSide if oversized so only the small + // bitmap is retained. Small icons are returned as-is (never upscaled) to preserve crispness. + private static Bitmap DecodeDownscaled(Stream stream) + { + var bitmap = new Bitmap(stream); + PixelSize size = bitmap.PixelSize; + if (size.Width <= MaxIconSide && size.Height <= MaxIconSide) + return bitmap; + + double scale = (double)MaxIconSide / Math.Max(size.Width, size.Height); + var target = new PixelSize( + Math.Max(1, (int)Math.Round(size.Width * scale)), + Math.Max(1, (int)Math.Round(size.Height * scale))); + + try + { + return bitmap.CreateScaledBitmap(target, BitmapInterpolationMode.HighQuality); + } + finally + { + bitmap.Dispose(); + } + } + private static bool IsSkiaDecodableExtension(string path) { string ext = Path.GetExtension(path); diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/Interface_PViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/Interface_PViewModel.cs index c85d3d372..bc74d8724 100644 --- a/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/Interface_PViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/SettingsPages/Interface_PViewModel.cs @@ -34,6 +34,7 @@ private async Task ResetIconCache(Visual? _) { try { Directory.Delete(CoreData.UniGetUICacheDirectory_Icons, true); } catch (Exception ex) { Logger.Error(ex); } + global::UniGetUI.PackageEngine.PackageClasses.PackageWrapper.ClearIconCache(); RestartRequired?.Invoke(this, EventArgs.Empty); await LoadIconCacheSize(); } From d992b4757cc8b620ebd148b50807859980d41037 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 9 Jul 2026 15:33:10 -0400 Subject: [PATCH 2/8] Enable GC ConserveMemory to return idle heap reserve to the OS --- src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index b4ca7fdb9..c4f8140a3 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -34,6 +34,13 @@ false + + + + + $(NoWarn);MVVMTK0045;AVLN3001 From c42608463688bac29690ad2ae7ed54c1764017a2 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 10 Jul 2026 08:46:00 -0400 Subject: [PATCH 3/8] Cancel package-list icon loads when the row is discarded --- .../Models/PackageCollections.cs | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src/UniGetUI.Avalonia/Models/PackageCollections.cs b/src/UniGetUI.Avalonia/Models/PackageCollections.cs index 4e8adfdce..29ce28155 100644 --- a/src/UniGetUI.Avalonia/Models/PackageCollections.cs +++ b/src/UniGetUI.Avalonia/Models/PackageCollections.cs @@ -135,6 +135,10 @@ public bool IsChecked public string InstallerHostChangeTooltip { get; private set; } = ""; private CancellationTokenSource? _installerHostCheckCts; + // Cancelled when the row is discarded (e.g. a new search). Icon loads are throttled, so a large + // result set queues many; without this, a discarded row's queued load keeps its async state + // machine — and the wrapper/package/bitmap it roots — alive, so RAM climbs with each search. + private readonly CancellationTokenSource _lifetimeCts = new(); public string SourceIconPath => IconTypeToSvgPath(Package.Source.IconId); @@ -245,6 +249,7 @@ await Dispatcher.UIThread.InvokeAsync(() => private async Task LoadIconAsync() { + CancellationToken token = _lifetimeCts.Token; long hash = Package.GetHash(); if (TryGetCachedIcon(hash, out Bitmap? cached)) { @@ -255,11 +260,13 @@ private async Task LoadIconAsync() try { - await _iconLoadSemaphore.WaitAsync().ConfigureAwait(false); + // Cancellable wait: if the row is discarded while queued behind the throttle, the load + // bails here instead of eventually running and rooting this wrapper. + await _iconLoadSemaphore.WaitAsync(token).ConfigureAwait(false); Bitmap bitmap; try { - var uri = await Task.Run(Package.GetIconUrlIfAny).ConfigureAwait(false); + var uri = await Task.Run(Package.GetIconUrlIfAny, token).ConfigureAwait(false); if (uri is null) { CacheIcon(hash, null); return; } Bitmap? decoded; @@ -272,11 +279,11 @@ private async Task LoadIconAsync() CacheIcon(hash, null); return; } - decoded = await Task.Run(() => TryDecodeIcon(uri.LocalPath)).ConfigureAwait(false); + decoded = await Task.Run(() => TryDecodeIcon(uri.LocalPath), token).ConfigureAwait(false); } else if (uri.Scheme is "http" or "https") { - var bytes = await _iconHttpClient.GetByteArrayAsync(uri).ConfigureAwait(false); + var bytes = await _iconHttpClient.GetByteArrayAsync(uri, token).ConfigureAwait(false); decoded = TryDecodeIcon(bytes, uri.Host); } else { CacheIcon(hash, null); return; } @@ -290,8 +297,13 @@ private async Task LoadIconAsync() _iconLoadSemaphore.Release(); } - await Dispatcher.UIThread.InvokeAsync(() => IconBitmap = bitmap); + if (token.IsCancellationRequested) return; + await Dispatcher.UIThread.InvokeAsync(() => + { + if (!token.IsCancellationRequested) IconBitmap = bitmap; + }); } + catch (OperationCanceledException) { /* row discarded before its icon finished loading */ } catch { CacheIcon(hash, null); } } @@ -400,6 +412,9 @@ public void Dispose() _installerHostCheckCts?.Cancel(); _installerHostCheckCts?.Dispose(); _installerHostCheckCts = null; + // Cancel any queued/in-flight icon load so it stops rooting this wrapper. Not disposed: + // a background load may still hold the token; the source is tiny and has no handle to leak. + _lifetimeCts.Cancel(); } } From f7ad189531c63efd40b800d5e8e3ce0ab931aade Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 10 Jul 2026 09:05:59 -0400 Subject: [PATCH 4/8] Load package-list icons lazily, only for visible rows --- .../Models/PackageCollections.cs | 21 ++++++-- .../Views/Controls/PackageIconLoader.cs | 54 +++++++++++++++++++ .../SoftwarePages/AbstractPackagesPage.axaml | 9 ++-- 3 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 src/UniGetUI.Avalonia/Views/Controls/PackageIconLoader.cs diff --git a/src/UniGetUI.Avalonia/Models/PackageCollections.cs b/src/UniGetUI.Avalonia/Models/PackageCollections.cs index 29ce28155..1ce20e0d9 100644 --- a/src/UniGetUI.Avalonia/Models/PackageCollections.cs +++ b/src/UniGetUI.Avalonia/Models/PackageCollections.cs @@ -27,7 +27,7 @@ public sealed class PackageWrapper : INotifyPropertyChanged, IDisposable { Timeout = TimeSpan.FromSeconds(8), }; - private static readonly SemaphoreSlim _iconLoadSemaphore = new(4, 4); + private static readonly SemaphoreSlim _iconLoadSemaphore = new(8, 8); // List icons render at 24-64px; decoding/caching them at native resolution (often 256-512px) // wastes ~10-60x the memory. Cap the cached side to cover 64px at 2x DPI. @@ -168,12 +168,25 @@ public PackageWrapper(IPackage package, PackagesPageViewModel page) Package.PropertyChanged += Package_PropertyChanged; UpdateDisplayState(); - if (!Settings.Get(Settings.K.DisableIconsOnPackageLists)) - _ = LoadIconAsync(); - + // Icon loading is deferred until the row is actually shown (see EnsureIconLoaded). Starting + // it here would eagerly load an icon for every result — thousands for a broad search — when + // the virtualized list only ever displays a few dozen at once. MaybeStartInstallerHostCheck(); } + private int _iconLoadStarted; + + /// + /// Starts loading this row's icon, at most once. Called when the row becomes visible so that + /// only on-screen rows load icons (see PackageIconLoader). + /// + public void EnsureIconLoaded() + { + if (Settings.Get(Settings.K.DisableIconsOnPackageLists)) return; + if (Interlocked.Exchange(ref _iconLoadStarted, 1) != 0) return; + _ = LoadIconAsync(); + } + /// /// For upgradable WinGet packages, asynchronously fetches the installer URL host for /// both the installed and the new version, and flags the row when the hosts differ. diff --git a/src/UniGetUI.Avalonia/Views/Controls/PackageIconLoader.cs b/src/UniGetUI.Avalonia/Views/Controls/PackageIconLoader.cs new file mode 100644 index 000000000..be61157cc --- /dev/null +++ b/src/UniGetUI.Avalonia/Views/Controls/PackageIconLoader.cs @@ -0,0 +1,54 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using UniGetUI.PackageEngine.PackageClasses; + +namespace UniGetUI.Avalonia.Views.Controls; + +/// +/// Attached behaviour that calls when the icon element +/// attaches to the visual tree or is rebound to a new wrapper. In the virtualized list only the +/// realized (visible) rows attach, so only those load their icons — instead of every result eagerly +/// loading one in the wrapper constructor (thousands for a broad search). +/// +public static class PackageIconLoader +{ + public static readonly AttachedProperty TrackProperty = + AvaloniaProperty.RegisterAttached("Track", typeof(PackageIconLoader)); + + public static void SetTrack(Control control, bool value) => control.SetValue(TrackProperty, value); + public static bool GetTrack(Control control) => control.GetValue(TrackProperty); + + static PackageIconLoader() + { + TrackProperty.Changed.AddClassHandler((control, e) => + { + if (e.GetNewValue()) + { + control.AttachedToVisualTree += OnAttached; + control.DataContextChanged += OnDataContextChanged; + if (control.IsLoaded) TryLoad(control); + } + else + { + control.AttachedToVisualTree -= OnAttached; + control.DataContextChanged -= OnDataContextChanged; + } + }); + } + + private static void OnAttached(object? sender, VisualTreeAttachmentEventArgs e) + => TryLoad((Control)sender!); + + private static void OnDataContextChanged(object? sender, EventArgs e) + { + var control = (Control)sender!; + // Only when realized: a recycled container fires this while detached too. + if (control.IsLoaded) TryLoad(control); + } + + private static void TryLoad(Control control) + { + if (control.DataContext is PackageWrapper wrapper) wrapper.EnsureIconLoaded(); + } +} diff --git a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml index 81f356252..27ae1d3aa 100644 --- a/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml +++ b/src/UniGetUI.Avalonia/Views/SoftwarePages/AbstractPackagesPage.axaml @@ -525,7 +525,8 @@ - + + VerticalAlignment="Center" HorizontalAlignment="Center" + controls:PackageIconLoader.Track="True"> + VerticalAlignment="Center" HorizontalAlignment="Center" + controls:PackageIconLoader.Track="True"> Date: Fri, 10 Jul 2026 09:49:06 -0400 Subject: [PATCH 5/8] Cap Discover search results so broad queries stay fast --- .../ClientHelpers/WinGetCliHelper.cs | 9 ++++++++- .../DiscoverablePackagesLoader.cs | 11 ++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs index 855c91870..4a27a648a 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs @@ -13,6 +13,11 @@ namespace UniGetUI.PackageEngine.Managers.WingetManager; internal sealed class WinGetCliHelper : IWinGetManagerHelper { + // "winget search a" returns ~12k results; parsing each into a Package and pushing them to the + // UI freezes the app for seconds and spikes RAM. Cap to the most relevant matches (winget + // orders by relevance), matching the bundled pinget's default behaviour. + private const int MAX_SEARCH_RESULTS = 100; + private readonly WinGet Manager; private readonly string _cliExecutablePath; private readonly IPingetPackageDetailsProvider _packageDetailsProvider; @@ -320,7 +325,9 @@ public IReadOnlyList FindPackages_UnSafe(string query) Manager.Status.ExecutableCallArgs + " search \"" + query - + "\" --accept-source-agreements " + + "\" --count " + + MAX_SEARCH_RESULTS + + " --accept-source-agreements " + WinGet.GetProxyArgument(), RedirectStandardOutput = true, RedirectStandardError = true, diff --git a/src/UniGetUI.PackageEngine.PackageLoader/DiscoverablePackagesLoader.cs b/src/UniGetUI.PackageEngine.PackageLoader/DiscoverablePackagesLoader.cs index 2a5d2e69e..61e323ca1 100644 --- a/src/UniGetUI.PackageEngine.PackageLoader/DiscoverablePackagesLoader.cs +++ b/src/UniGetUI.PackageEngine.PackageLoader/DiscoverablePackagesLoader.cs @@ -8,6 +8,12 @@ public class DiscoverablePackagesLoader : AbstractPackageLoader { public static DiscoverablePackagesLoader Instance = null!; + // A broad query (e.g. "a") makes each manager return thousands of results; aggregated across + // every enabled manager that is thousands of packages to wrap, tag (per-package upgradable/ + // installed lookups) and render, which freezes the UI and spikes RAM. Keep only the most + // relevant matches per manager — nobody scrolls thousands of results. + private const int MAX_RESULTS_PER_MANAGER = 100; + private string QUERY_TEXT = string.Empty; private volatile bool _pendingReload; @@ -70,7 +76,10 @@ protected override IReadOnlyList LoadPackagesFromManager(IPackageManag return []; } - return manager.FindPackages(text); + IReadOnlyList found = manager.FindPackages(text); + return found.Count > MAX_RESULTS_PER_MANAGER + ? found.Take(MAX_RESULTS_PER_MANAGER).ToArray() + : found; } protected override Task WhenAddingPackage(IPackage package) From 5e41a9ed36ae161e32290bcb7c8340315c844521 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 10 Jul 2026 10:21:53 -0400 Subject: [PATCH 6/8] Bound retained manager task logs --- .../Manager/Classes/ManagerLogger.cs | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/ManagerLogger.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/ManagerLogger.cs index 9ae3929d1..9e853e64b 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/ManagerLogger.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/ManagerLogger.cs @@ -9,10 +9,14 @@ public class ManagerLogger : IManagerLogger { private readonly IPackageManager Manager; + // Every task (search, list, install, ...) is retained here for the manager-log viewer. Left + // unbounded, a session's worth of operations — each holding its full output — grows without + // limit (a broad search alone spawns one per manager). Keep only the most recent. + private const int MAX_RETAINED_OPERATIONS = 100; private readonly List operations = []; public IReadOnlyList Operations { - get => (IReadOnlyList)operations; + get { lock (operations) return operations.ToArray(); } } public ManagerLogger(IPackageManager manager) @@ -20,6 +24,16 @@ public ManagerLogger(IPackageManager manager) Manager = manager; } + private void Register(TaskLogger operation) + { + lock (operations) + { + operations.Add(operation); + if (operations.Count > MAX_RETAINED_OPERATIONS) + operations.RemoveRange(0, operations.Count - MAX_RETAINED_OPERATIONS); + } + } + public IProcessTaskLogger CreateNew(LoggableTaskType type, Process process) { if (process.StartInfo is null) @@ -35,20 +49,33 @@ public IProcessTaskLogger CreateNew(LoggableTaskType type, Process process) process.StartInfo.FileName, process.StartInfo.Arguments ); - operations.Add(operation); + Register(operation); return operation; } public INativeTaskLogger CreateNew(LoggableTaskType type) { NativeTaskLogger operation = new(Manager, type); - operations.Add(operation); + Register(operation); return operation; } } public abstract class TaskLogger : ITaskLogger { + // Cap retained output per stream. A broad "search" can emit thousands of lines per manager; + // keeping them all across every operation is what makes memory climb with each search. The + // manager-log viewer only needs the tail. Trimmed in chunks to keep appending O(1). + protected const int MaxRetainedLines = 1000; + private const int TrimSlack = 256; + + protected static void AppendCapped(List target, string line) + { + target.Add(line); + if (target.Count > MaxRetainedLines + TrimSlack) + target.RemoveRange(0, target.Count - MaxRetainedLines); + } + protected DateTime StartTime; protected DateTime? EndTime; protected bool isComplete; @@ -167,7 +194,7 @@ public void AddToStdOut(IReadOnlyList lines) { if (line != "") { - StdOut.Add(line); + AppendCapped(StdOut, line); } } } @@ -193,7 +220,7 @@ public void AddToStdErr(IReadOnlyList lines) { if (line != "") { - StdErr.Add(line); + AppendCapped(StdErr, line); } } } @@ -325,7 +352,7 @@ public void Log(IReadOnlyList lines) { if (line != "") { - Info.Add(line); + AppendCapped(Info, line); } } } @@ -351,7 +378,7 @@ public void Error(IReadOnlyList lines) { if (line != "") { - Errors.Add(line); + AppendCapped(Errors, line); } } } From 28403c3088787cfe4e0d05c0fedb550f6a8560e7 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 10 Jul 2026 10:40:18 -0400 Subject: [PATCH 7/8] Return memory to the OS after a package load finishes --- .../Infrastructure/MemoryTrimmer.cs | 90 +++++++++++++++++++ .../UniGetUI.Avalonia.csproj | 1 + .../ViewModels/MainWindowViewModel.cs | 8 ++ 3 files changed, 99 insertions(+) create mode 100644 src/UniGetUI.Avalonia/Infrastructure/MemoryTrimmer.cs diff --git a/src/UniGetUI.Avalonia/Infrastructure/MemoryTrimmer.cs b/src/UniGetUI.Avalonia/Infrastructure/MemoryTrimmer.cs new file mode 100644 index 000000000..d90fbff2f --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/MemoryTrimmer.cs @@ -0,0 +1,90 @@ +using System; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Avalonia.Threading; +using UniGetUI.Core.Logging; + +namespace UniGetUI.Avalonia.Infrastructure; + +/// +/// Returns reclaimable memory to the OS once a package load has settled. .NET and the native +/// allocator keep freed memory committed for reuse, so a search/refresh leaves the working set high +/// even after the app goes idle. This collects the managed heap and hands the freed pages back so +/// the reported footprint drops once the list has finished loading. Windows-only, best-effort. +/// +/// Debounced: a trim is scheduled when a load finishes and cancelled if another load starts, so it +/// only runs when everything is quiet (and never mid-load). +/// +internal static class MemoryTrimmer +{ + private static readonly TimeSpan SettleDelay = TimeSpan.FromSeconds(3); + private static DispatcherTimer? _timer; + + /// Schedule a trim once loading has been quiet for a moment. + public static void RequestTrimAfterIdle() + { + if (!OperatingSystem.IsWindows()) return; + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(RequestTrimAfterIdle); + return; + } + + _timer ??= CreateTimer(); + _timer.Stop(); + _timer.Start(); + } + + /// Cancel a pending trim because a new load has started. + public static void CancelPending() + { + if (!OperatingSystem.IsWindows()) return; + if (!Dispatcher.UIThread.CheckAccess()) + { + Dispatcher.UIThread.Post(CancelPending); + return; + } + + _timer?.Stop(); + } + + private static DispatcherTimer CreateTimer() + { + var timer = new DispatcherTimer { Interval = SettleDelay }; + timer.Tick += (_, _) => + { + timer.Stop(); + Trim(); + }; + return timer; + } + + private static void Trim() => _ = Task.Run(() => + { + try + { + GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true); + GC.WaitForPendingFinalizers(); + GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true); + NativeMethods.SetProcessWorkingSetSize(NativeMethods.GetCurrentProcess(), -1, -1); + } + catch (Exception ex) + { + Logger.Warn($"Post-load working-set trim failed: {ex.Message}"); + } + }); + + private static class NativeMethods + { + [DllImport("kernel32.dll")] + public static extern nint GetCurrentProcess(); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool SetProcessWorkingSetSize( + nint hProcess, + nint dwMinimumWorkingSetSize, + nint dwMaximumWorkingSetSize + ); + } +} diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index c4f8140a3..14b99c345 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -236,6 +236,7 @@ + diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 6adf89033..72decebf9 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -199,9 +199,17 @@ public MainWindowViewModel() if (loader is null) continue; var pt = pageType; loader.FinishedLoading += (_, _) => + { Dispatcher.UIThread.Post(() => Sidebar.SetNavItemLoading(pt, false)); + // A load (search/refresh) balloons the working set; once it settles, return the + // reclaimable memory to the OS so the footprint drops back down. + Infrastructure.MemoryTrimmer.RequestTrimAfterIdle(); + }; loader.StartedLoading += (_, _) => + { Dispatcher.UIThread.Post(() => Sidebar.SetNavItemLoading(pt, true)); + Infrastructure.MemoryTrimmer.CancelPending(); + }; Sidebar.SetNavItemLoading(pt, loader.IsLoading); } From 4b2eeab83d900ca0a8bdd69291d020bcffebf2e0 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Fri, 10 Jul 2026 10:53:28 -0400 Subject: [PATCH 8/8] Trim verbose comments to one-liners --- .../Infrastructure/MemoryTrimmer.cs | 14 ++------ .../Models/PackageCollections.cs | 32 +++++-------------- .../UniGetUI.Avalonia.csproj | 4 +-- .../ViewModels/MainWindowViewModel.cs | 3 +- .../Views/Controls/PackageIconLoader.cs | 10 ++---- .../ClientHelpers/WinGetCliHelper.cs | 4 +-- .../DiscoverablePackagesLoader.cs | 6 ++-- .../Manager/Classes/ManagerLogger.cs | 8 ++--- 8 files changed, 21 insertions(+), 60 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/MemoryTrimmer.cs b/src/UniGetUI.Avalonia/Infrastructure/MemoryTrimmer.cs index d90fbff2f..04499a21b 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/MemoryTrimmer.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/MemoryTrimmer.cs @@ -6,21 +6,14 @@ namespace UniGetUI.Avalonia.Infrastructure; -/// -/// Returns reclaimable memory to the OS once a package load has settled. .NET and the native -/// allocator keep freed memory committed for reuse, so a search/refresh leaves the working set high -/// even after the app goes idle. This collects the managed heap and hands the freed pages back so -/// the reported footprint drops once the list has finished loading. Windows-only, best-effort. -/// -/// Debounced: a trim is scheduled when a load finishes and cancelled if another load starts, so it -/// only runs when everything is quiet (and never mid-load). -/// +// Collects and returns the working set to the OS once loading has settled — neither .NET nor the +// native allocator hand freed memory back on their own. Debounced so it never runs mid-load. +// Windows-only, best-effort. internal static class MemoryTrimmer { private static readonly TimeSpan SettleDelay = TimeSpan.FromSeconds(3); private static DispatcherTimer? _timer; - /// Schedule a trim once loading has been quiet for a moment. public static void RequestTrimAfterIdle() { if (!OperatingSystem.IsWindows()) return; @@ -35,7 +28,6 @@ public static void RequestTrimAfterIdle() _timer.Start(); } - /// Cancel a pending trim because a new load has started. public static void CancelPending() { if (!OperatingSystem.IsWindows()) return; diff --git a/src/UniGetUI.Avalonia/Models/PackageCollections.cs b/src/UniGetUI.Avalonia/Models/PackageCollections.cs index 1ce20e0d9..b2220f5d6 100644 --- a/src/UniGetUI.Avalonia/Models/PackageCollections.cs +++ b/src/UniGetUI.Avalonia/Models/PackageCollections.cs @@ -29,14 +29,11 @@ public sealed class PackageWrapper : INotifyPropertyChanged, IDisposable }; private static readonly SemaphoreSlim _iconLoadSemaphore = new(8, 8); - // List icons render at 24-64px; decoding/caching them at native resolution (often 256-512px) - // wastes ~10-60x the memory. Cap the cached side to cover 64px at 2x DPI. + // Cap decoded icon size; the list shows icons at ≤64px (128 covers 2x DPI). private const int MaxIconSide = 128; - // Bounded LRU of decoded icons keyed by package hash. Was previously an unbounded static - // dictionary that kept every icon ever shown resident for the whole session. Entries are not - // disposed on eviction: the same Bitmap may still be bound to a visible row, and any wrapper - // still referencing it keeps it alive; dropping it here only makes it eligible for GC. + // Bounded LRU by package hash. Evicted entries aren't disposed: a visible row may still + // reference the bitmap, so dropping it here only makes it GC-eligible. private const int MaxIconCacheEntries = 512; private static readonly object _iconCacheLock = new(); private static readonly Dictionary> _iconCache = new(); @@ -82,7 +79,6 @@ private static void CacheIcon(long hash, Bitmap? bitmap) } } - /// Drops all cached decoded icons. Bound bitmaps stay alive via their own references. public static void ClearIconCache() { lock (_iconCacheLock) @@ -135,9 +131,7 @@ public bool IsChecked public string InstallerHostChangeTooltip { get; private set; } = ""; private CancellationTokenSource? _installerHostCheckCts; - // Cancelled when the row is discarded (e.g. a new search). Icon loads are throttled, so a large - // result set queues many; without this, a discarded row's queued load keeps its async state - // machine — and the wrapper/package/bitmap it roots — alive, so RAM climbs with each search. + // Cancels this row's queued/in-flight icon load on disposal so it stops rooting the wrapper. private readonly CancellationTokenSource _lifetimeCts = new(); public string SourceIconPath => IconTypeToSvgPath(Package.Source.IconId); @@ -168,18 +162,13 @@ public PackageWrapper(IPackage package, PackagesPageViewModel page) Package.PropertyChanged += Package_PropertyChanged; UpdateDisplayState(); - // Icon loading is deferred until the row is actually shown (see EnsureIconLoaded). Starting - // it here would eagerly load an icon for every result — thousands for a broad search — when - // the virtualized list only ever displays a few dozen at once. + // Icons load lazily per visible row (see EnsureIconLoaded), not eagerly for every result. MaybeStartInstallerHostCheck(); } private int _iconLoadStarted; - /// - /// Starts loading this row's icon, at most once. Called when the row becomes visible so that - /// only on-screen rows load icons (see PackageIconLoader). - /// + /// Loads this row's icon at most once; called when the row becomes visible. public void EnsureIconLoaded() { if (Settings.Get(Settings.K.DisableIconsOnPackageLists)) return; @@ -273,8 +262,6 @@ private async Task LoadIconAsync() try { - // Cancellable wait: if the row is discarded while queued behind the throttle, the load - // bails here instead of eventually running and rooting this wrapper. await _iconLoadSemaphore.WaitAsync(token).ConfigureAwait(false); Bitmap bitmap; try @@ -338,8 +325,7 @@ await Dispatcher.UIThread.InvokeAsync(() => catch (Exception ex) { Logger.Debug($"Discarding undecodable icon '{source}': {ex.Message}"); return null; } } - // Decodes at native resolution, then downscales to MaxIconSide if oversized so only the small - // bitmap is retained. Small icons are returned as-is (never upscaled) to preserve crispness. + // Downscales oversized icons to MaxIconSide; small icons pass through (never upscaled). private static Bitmap DecodeDownscaled(Stream stream) { var bitmap = new Bitmap(stream); @@ -425,9 +411,7 @@ public void Dispose() _installerHostCheckCts?.Cancel(); _installerHostCheckCts?.Dispose(); _installerHostCheckCts = null; - // Cancel any queued/in-flight icon load so it stops rooting this wrapper. Not disposed: - // a background load may still hold the token; the source is tiny and has no handle to leak. - _lifetimeCts.Cancel(); + _lifetimeCts.Cancel(); // not disposed: a background load may still hold the token } } diff --git a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj index 14b99c345..b0875d7cc 100644 --- a/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj +++ b/src/UniGetUI.Avalonia/UniGetUI.Avalonia.csproj @@ -35,9 +35,7 @@ - + diff --git a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs index 72decebf9..3c998b23c 100644 --- a/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/MainWindowViewModel.cs @@ -201,8 +201,7 @@ public MainWindowViewModel() loader.FinishedLoading += (_, _) => { Dispatcher.UIThread.Post(() => Sidebar.SetNavItemLoading(pt, false)); - // A load (search/refresh) balloons the working set; once it settles, return the - // reclaimable memory to the OS so the footprint drops back down. + // Return the load's memory to the OS once it settles. Infrastructure.MemoryTrimmer.RequestTrimAfterIdle(); }; loader.StartedLoading += (_, _) => diff --git a/src/UniGetUI.Avalonia/Views/Controls/PackageIconLoader.cs b/src/UniGetUI.Avalonia/Views/Controls/PackageIconLoader.cs index be61157cc..706b3cb8f 100644 --- a/src/UniGetUI.Avalonia/Views/Controls/PackageIconLoader.cs +++ b/src/UniGetUI.Avalonia/Views/Controls/PackageIconLoader.cs @@ -5,12 +5,8 @@ namespace UniGetUI.Avalonia.Views.Controls; -/// -/// Attached behaviour that calls when the icon element -/// attaches to the visual tree or is rebound to a new wrapper. In the virtualized list only the -/// realized (visible) rows attach, so only those load their icons — instead of every result eagerly -/// loading one in the wrapper constructor (thousands for a broad search). -/// +// Calls PackageWrapper.EnsureIconLoaded when the icon element attaches or is rebound, so only +// realized (visible) rows in the virtualized list load their icons. public static class PackageIconLoader { public static readonly AttachedProperty TrackProperty = @@ -43,7 +39,7 @@ private static void OnAttached(object? sender, VisualTreeAttachmentEventArgs e) private static void OnDataContextChanged(object? sender, EventArgs e) { var control = (Control)sender!; - // Only when realized: a recycled container fires this while detached too. + // A recycled container also fires this while detached; only load when realized. if (control.IsLoaded) TryLoad(control); } diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs index 4a27a648a..1ee0bdaeb 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/ClientHelpers/WinGetCliHelper.cs @@ -13,9 +13,7 @@ namespace UniGetUI.PackageEngine.Managers.WingetManager; internal sealed class WinGetCliHelper : IWinGetManagerHelper { - // "winget search a" returns ~12k results; parsing each into a Package and pushing them to the - // UI freezes the app for seconds and spikes RAM. Cap to the most relevant matches (winget - // orders by relevance), matching the bundled pinget's default behaviour. + // "winget search a" returns ~12k results; cap to the most relevant to avoid the freeze/RAM spike. private const int MAX_SEARCH_RESULTS = 100; private readonly WinGet Manager; diff --git a/src/UniGetUI.PackageEngine.PackageLoader/DiscoverablePackagesLoader.cs b/src/UniGetUI.PackageEngine.PackageLoader/DiscoverablePackagesLoader.cs index 61e323ca1..4ad94f68e 100644 --- a/src/UniGetUI.PackageEngine.PackageLoader/DiscoverablePackagesLoader.cs +++ b/src/UniGetUI.PackageEngine.PackageLoader/DiscoverablePackagesLoader.cs @@ -8,10 +8,8 @@ public class DiscoverablePackagesLoader : AbstractPackageLoader { public static DiscoverablePackagesLoader Instance = null!; - // A broad query (e.g. "a") makes each manager return thousands of results; aggregated across - // every enabled manager that is thousands of packages to wrap, tag (per-package upgradable/ - // installed lookups) and render, which freezes the UI and spikes RAM. Keep only the most - // relevant matches per manager — nobody scrolls thousands of results. + // Cap results per manager; a broad query across every enabled manager otherwise wraps, + // tags and renders thousands of packages, freezing the UI and spiking RAM. private const int MAX_RESULTS_PER_MANAGER = 100; private string QUERY_TEXT = string.Empty; diff --git a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/ManagerLogger.cs b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/ManagerLogger.cs index 9e853e64b..31ec77f1d 100644 --- a/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/ManagerLogger.cs +++ b/src/UniGetUI.PackageEngine.PackageManagerClasses/Manager/Classes/ManagerLogger.cs @@ -9,9 +9,7 @@ public class ManagerLogger : IManagerLogger { private readonly IPackageManager Manager; - // Every task (search, list, install, ...) is retained here for the manager-log viewer. Left - // unbounded, a session's worth of operations — each holding its full output — grows without - // limit (a broad search alone spawns one per manager). Keep only the most recent. + // Keep only recent operations; retained unbounded, each holds its full output and grows forever. private const int MAX_RETAINED_OPERATIONS = 100; private readonly List operations = []; public IReadOnlyList Operations @@ -63,9 +61,7 @@ public INativeTaskLogger CreateNew(LoggableTaskType type) public abstract class TaskLogger : ITaskLogger { - // Cap retained output per stream. A broad "search" can emit thousands of lines per manager; - // keeping them all across every operation is what makes memory climb with each search. The - // manager-log viewer only needs the tail. Trimmed in chunks to keep appending O(1). + // Cap retained output per stream (chunk-trimmed to keep appending O(1)); a search can emit thousands of lines. protected const int MaxRetainedLines = 1000; private const int TrimSlack = 256;