Skip to content

Commit 1fe510a

Browse files
committed
fix(gui): eliminate GDI/resource leaks + harden crash log diagnostics
AppIcons.cs: dispose inline Pen (DrawUpIcon) and SolidBrush (DrawActivityIcon); dispose all bitmaps in InvalidateCache() to prevent GDI handle accumulation. TweakBrowserPanel.cs: add Dispose override for _debounce timer. MainForm.Designer.cs: dispose non-container-managed resources (_searchDebounceTimer, _profileScheduleTimer, _catScoreTip, _listItemTip, _cts). MainForm.cs: unsubscribe sidebar/treeview events in OnFormClosing. BasePackageManagerDialog.cs: dispose CancellationTokenSource on FormClosed. RoundedPanel.cs: cache Region and only recreate on size change (was per-paint); add Dispose override for cached Region. Program.cs: expand crash report with GC generation counts, heap size, finalizer queue depth, thread pool stats, and loaded assembly list. AppIconsTests: update InvalidateCache test to verify bitmaps ARE disposed. Total: 7,189 tweaks, 3,165 tests (0 failures)
1 parent 68c00eb commit 1fe510a

8 files changed

Lines changed: 101 additions & 17 deletions

File tree

src/RegiLattice.GUI/AppIcons.cs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,16 +88,16 @@ internal static Bitmap MenuBitmap(string key, Action<Graphics, int> draw)
8888

8989
/// <summary>Invalidate the cache (call after theme change).</summary>
9090
/// <remarks>
91-
/// Icons are disposed immediately (form icons are reassigned right after).
92-
/// Bitmap entries are NOT disposed here because ToolStripMenuItem.Image may
93-
/// still reference them. The caller must reassign menu images; old bitmaps
94-
/// become unreferenced and are collected by GC/finalizer.
91+
/// Both icons and bitmaps are disposed immediately to prevent GDI handle leaks.
92+
/// Callers must reassign any ToolStripMenuItem.Image references after this call.
9593
/// </remarks>
9694
internal static void InvalidateCache()
9795
{
9896
foreach (var icon in _cache.Values)
9997
icon.Dispose();
10098
_cache.Clear();
99+
foreach (var bmp in _bmpCache.Values)
100+
bmp.Dispose();
101101
_bmpCache.Clear();
102102
}
103103

@@ -561,7 +561,8 @@ private static void DrawExportIcon(Graphics g, int s)
561561
using var pen = new Pen(Color.White, 2f) { EndCap = System.Drawing.Drawing2D.LineCap.ArrowAnchor };
562562
int cx = s / 2;
563563
g.DrawLine(pen, cx, s - 6, cx, 5);
564-
g.DrawLine(new Pen(Color.White, 1.5f), s / 4, s - 5, s - s / 4, s - 5);
564+
using var baseLine = new Pen(Color.White, 1.5f);
565+
g.DrawLine(baseLine, s / 4, s - 5, s - s / 4, s - 5);
565566
}
566567

567568
// ── Menu bitmap accessors for new icons ─────────────────────────────
@@ -931,7 +932,8 @@ private static void DrawPreferencesIcon(Graphics g, int s)
931932
cy + (int)((r + 2) * Math.Sin(a2))
932933
);
933934
}
934-
g.FillEllipse(new SolidBrush(AppTheme.Bg), cx - ir + 1, cy - ir + 1, (ir - 1) * 2, (ir - 1) * 2);
935+
using var centerBrush = new SolidBrush(AppTheme.Bg);
936+
g.FillEllipse(centerBrush, cx - ir + 1, cy - ir + 1, (ir - 1) * 2, (ir - 1) * 2);
935937
}
936938

937939
/// <summary>Import icon: green downward arrow into a tray.</summary>

src/RegiLattice.GUI/Controls/RoundedPanel.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ public sealed class RoundedPanel : Panel
1515
private int _borderWidth = 1;
1616
private float _tintAlpha = 0f; // 0 = solid BackColor, >0 = tinted
1717
private Color _tintColor = Color.White;
18+
private Region? _cachedRegion;
19+
private Size _lastRegionSize;
1820

1921
// ── Properties ─────────────────────────────────────────────────────────
2022
/// <summary>Corner radius in pixels (default 10).</summary>
@@ -121,14 +123,27 @@ protected override void OnPaint(PaintEventArgs e)
121123
g.DrawPath(pen, path);
122124
}
123125

124-
// Clip child controls to rounded region — dispose old Region to prevent GDI leak
126+
// Clip child controls to rounded region — only recreate when size changes.
127+
if (_cachedRegion is null || _lastRegionSize != Size)
128+
{
129+
_cachedRegion?.Dispose();
130+
_cachedRegion = new Region(path);
131+
_lastRegionSize = Size;
132+
}
125133
var oldRegion = Region;
126-
Region = new Region(path);
134+
Region = _cachedRegion.Clone();
127135
oldRegion?.Dispose();
128136

129137
base.OnPaint(e);
130138
}
131139

140+
protected override void Dispose(bool disposing)
141+
{
142+
if (disposing)
143+
_cachedRegion?.Dispose();
144+
base.Dispose(disposing);
145+
}
146+
132147
// ── Helper ─────────────────────────────────────────────────────────────
133148
private static GraphicsPath RoundedPath(Rectangle r, int radius)
134149
{

src/RegiLattice.GUI/Controls/TweakBrowserPanel.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,16 @@ private async void OnToggleRequested(TweakDef td, bool enable)
345345
}
346346

347347
private void OnInfoRequested(TweakDef td) => TweakInfoRequested?.Invoke(td);
348+
349+
protected override void Dispose(bool disposing)
350+
{
351+
if (disposing)
352+
{
353+
_debounce.Stop();
354+
_debounce.Dispose();
355+
}
356+
base.Dispose(disposing);
357+
}
348358
}
349359

350360
/// <summary>

src/RegiLattice.GUI/Forms/BasePackageManagerDialog.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,11 @@ protected BasePackageManagerDialog()
9696
if (_prereqMet)
9797
await RefreshAsync();
9898
};
99-
FormClosed += (_, _) => _cts.Cancel();
99+
FormClosed += (_, _) =>
100+
{
101+
_cts.Cancel();
102+
_cts.Dispose();
103+
};
100104
}
101105

102106
// ── Layout ────────────────────────────────────────────────────────────

src/RegiLattice.GUI/Forms/MainForm.Designer.cs

Lines changed: 9 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/RegiLattice.GUI/Forms/MainForm.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,11 @@ protected override void OnFormClosing(FormClosingEventArgs e)
237237

238238
_trayIcon.Visible = false;
239239
_cts.Cancel();
240+
241+
// Unsubscribe control events to break reference cycles before disposal.
242+
_sidebar.ItemSelected -= OnSidebarNavSelected;
243+
_treeView.NodeMouseHover -= OnTreeNodeScoreHover;
244+
240245
base.OnFormClosing(e);
241246
}
242247

src/RegiLattice.GUI/Program.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,33 @@ private static void AppendHandleStats(System.Text.StringBuilder sb)
171171
sb.AppendLine("(handle/memory stats unavailable)");
172172
}
173173

174+
// GC generation counts and memory pressure
175+
try
176+
{
177+
sb.AppendLine($"GC Gen0 colls: {GC.CollectionCount(0):N0}");
178+
sb.AppendLine($"GC Gen1 colls: {GC.CollectionCount(1):N0}");
179+
sb.AppendLine($"GC Gen2 colls: {GC.CollectionCount(2):N0}");
180+
var gcInfo = GC.GetGCMemoryInfo();
181+
sb.AppendLine($"GC HeapSize : {gcInfo.HeapSizeBytes / (1024 * 1024):N0} MB");
182+
sb.AppendLine($"GC Finalize Q: {gcInfo.FinalizationPendingCount:N0} pending");
183+
}
184+
catch
185+
{
186+
sb.AppendLine("(GC details unavailable)");
187+
}
188+
189+
// Thread pool stats — helps diagnose deadlocks and thread exhaustion
190+
try
191+
{
192+
ThreadPool.GetAvailableThreads(out int workerAvail, out int ioAvail);
193+
ThreadPool.GetMaxThreads(out int workerMax, out int ioMax);
194+
sb.AppendLine($"ThreadPool : workers {workerMax - workerAvail}/{workerMax}, IO {ioMax - ioAvail}/{ioMax}");
195+
}
196+
catch
197+
{
198+
sb.AppendLine("(thread pool stats unavailable)");
199+
}
200+
174201
// Open forms and total control count — helps identify control/handle leaks
175202
try
176203
{
@@ -185,6 +212,22 @@ private static void AppendHandleStats(System.Text.StringBuilder sb)
185212
{
186213
sb.AppendLine("(form/control stats unavailable)");
187214
}
215+
216+
// Loaded assemblies — helps diagnose version conflicts and plugin issues
217+
try
218+
{
219+
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
220+
sb.AppendLine($"Loaded asms : {assemblies.Length}");
221+
foreach (var asm in assemblies)
222+
{
223+
var name = asm.GetName();
224+
sb.AppendLine($" {name.Name} {name.Version}");
225+
}
226+
}
227+
catch
228+
{
229+
sb.AppendLine("(assembly list unavailable)");
230+
}
188231
}
189232

190233
private static int CountControls(Control c)

tests/RegiLattice.GUI.Tests/AppIconsTests.cs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,18 +99,16 @@ public void InvalidateCache_ThenMenuBitmaps_AreValidAndFresh()
9999
}
100100

101101
[Fact]
102-
public void InvalidateCache_OldBitmaps_StillAccessible()
102+
public void InvalidateCache_OldBitmaps_AreDisposed()
103103
{
104104
// Capture a reference to the old bitmap (like a ToolStripMenuItem.Image would)
105105
var oldBmp = AppIcons.PipMenuBitmap;
106106

107107
AppIcons.InvalidateCache();
108108

109-
// The old bitmap must NOT be disposed — FrameDimensionsList access must not throw.
110-
// This is the exact operation that crashed before the fix.
111-
var dims = oldBmp.FrameDimensionsList;
112-
Assert.NotNull(dims);
113-
Assert.NotEmpty(dims);
109+
// Old bitmaps are now properly disposed to prevent GDI handle leaks.
110+
// Callers must reassign menu images after InvalidateCache().
111+
Assert.Throws<ArgumentException>(() => _ = oldBmp.FrameDimensionsList);
114112
}
115113

116114
[Fact]

0 commit comments

Comments
 (0)