Handle handle leaks on dragdrop cursor icon

Also pass the tooltips to the components container so that they dispose of anything if needed too.
A user had a long-running script/session that drag-dropped a few thousand times, which exhausted the Windows GDI handle limit (10,000 per process).
This commit is contained in:
Kurt 2026-03-09 12:25:15 -05:00
parent c7f02bcc20
commit 5b42ff746d
14 changed files with 101 additions and 22 deletions

View File

@ -136,7 +136,7 @@ public int GetAverageColor()
}
}
private static Span<byte> GetSpan(IntPtr ptr, int length)
private static Span<byte> GetSpan(nint ptr, int length)
=> MemoryMarshal.CreateSpan(ref Unsafe.AddByteOffset(ref Unsafe.NullRef<byte>(), ptr), length);
public static Bitmap LayerImage(Bitmap baseLayer, Bitmap overLayer, int x, int y, double transparency)

View File

@ -86,6 +86,7 @@ public SAVEditor()
SL_Extra.ViewIndex = -2;
menu = new ContextMenuSAV { Manager = M };
components!.Add(menu);
InitializeEvents();
}
@ -1497,7 +1498,8 @@ private async void TabMouseMove(object sender, MouseEventArgs e)
{
using var img = new Bitmap(Box.Width, Box.Height);
Box.DrawToBitmap(img, new Rectangle(0, 0, Box.Width, Box.Height));
using var cursor = Cursor = new Cursor(img.GetHicon());
using var dragCursor = new BitmapCursor(img);
Cursor = dragCursor.Cursor;
await File.WriteAllBytesAsync(newFile, bin).ConfigureAwait(true);
DoDragDrop(new DataObject(DataFormats.FileDrop, new[] { newFile }), DragDropEffects.Copy);
}

View File

@ -85,7 +85,7 @@ public void QueryContinueDrag(object? sender, QueryContinueDragEventArgs e)
public void DragEnter(object? sender, DragEventArgs e)
{
if (sender is null)
if (sender is not Control c)
return;
if ((e.AllowedEffect & DragDropEffects.Copy) != 0) // external file
e.Effect = DragDropEffects.Copy;
@ -93,7 +93,7 @@ public void DragEnter(object? sender, DragEventArgs e)
e.Effect = DragDropEffects.Move;
if (Drag.Info.IsDragDropInProgress)
Drag.SetCursor(((Control)sender).FindForm(), Drag.Info.Cursor);
Drag.SetCursor(c, Drag.Info.Cursor);
}
private static SlotViewInfo<T> GetSlotInfo<T>(T pb) where T : Control
@ -156,7 +156,7 @@ private void HandleMovePKM(PictureBox pb, bool encrypt)
// drop finished, clean up
Drag.Info.Source = null;
Drag.Reset();
Drag.ResetCursor(pb.FindForm());
Drag.ResetCursor(pb);
// Browser apps need time to load data since the file isn't moved to a location on the user's local storage.
// Tested 10ms -> too quick, 100ms was fine. 500ms should be safe?
@ -210,7 +210,7 @@ private bool TryMakeDragDropPKM(PictureBox pb, ReadOnlySpan<byte> data, string n
ArgumentNullException.ThrowIfNull(img);
File.WriteAllBytes(newfile, data);
Drag.SetCursor(pb.FindForm(), new Cursor(img.GetHicon()));
Drag.SetOwnedCursor(pb, img);
Hover.Stop();
pb.Image = null;
pb.BackgroundImage = SpriteUtil.Spriter.Drag;
@ -223,7 +223,7 @@ private bool TryMakeDragDropPKM(PictureBox pb, ReadOnlySpan<byte> data, string n
{
pb.Image = img;
pb.BackgroundImage = LastSlot.OriginalBackground;
Drag.ResetCursor(pb.FindForm());
Drag.ResetCursor(pb);
return external;
}
@ -347,7 +347,7 @@ private bool TrySetPKMDestination(PictureBox pb, DropModifier mod)
// Copy from temp to destination slot.
var type = info.IsDragSwap ? SlotTouchType.Swap : SlotTouchType.Set;
Env.Slots.Set(info.Destination!.Slot, pk, type);
Drag.ResetCursor(pb.FindForm());
Drag.ResetCursor(pb);
return true;
}
@ -381,6 +381,7 @@ public void SwapBoxes(int index, int other, SaveFile SAV)
public void Dispose()
{
Drag.Dispose();
Hover.Dispose();
SE.Dispose();
LastSlot.OriginalBackground?.Dispose();

View File

@ -0,0 +1,38 @@
using System;
using System.Drawing;
using System.Runtime.InteropServices;
using System.Windows.Forms;
namespace PKHeX.WinForms.Controls;
/// <summary>
/// Managed <see cref="Cursor"/> implementation that acquires the cursor appearance from a <see cref="Bitmap"/> and properly disposes of the underlying icon handle when done.
/// </summary>
public sealed class BitmapCursor : IDisposable
{
public Cursor Cursor { get; }
private nint IconHandle;
public BitmapCursor(Bitmap bitmap)
{
IconHandle = bitmap.GetHicon(); // creates a handle we need to dispose of.
Cursor = new Cursor(IconHandle);
}
// Need to use DestroyIcon as Cursor does not take ownership of the icon handle and will not destroy it when disposed.
#pragma warning disable SYSLIB1054
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DestroyIcon(nint hIcon);
#pragma warning restore SYSLIB1054
public void Dispose()
{
Cursor.Dispose();
if (IconHandle == nint.Zero)
return;
_ = DestroyIcon(IconHandle);
IconHandle = nint.Zero;
}
}

View File

@ -1,3 +1,4 @@
using System;
using System.Drawing;
using System.Windows.Forms;
@ -6,7 +7,7 @@ namespace PKHeX.WinForms.Controls;
/// <summary>
/// Manages drag-and-drop operations for slot controls.
/// </summary>
public sealed class DragManager
public sealed class DragManager : IDisposable
{
/// <summary>
/// Gets the current slot change information for drag-and-drop operations.
@ -17,6 +18,7 @@ public sealed class DragManager
/// Occurs when an external drag-and-drop operation is requested.
/// </summary>
public event DragEventHandler? RequestExternalDragDrop;
private BitmapCursor? OwnedCursor;
/// <summary>
/// Requests a drag-and-drop operation.
@ -30,17 +32,26 @@ public sealed class DragManager
/// </summary>
/// <param name="f">The form to set the cursor for.</param>
/// <param name="z">The cursor to set.</param>
public void SetCursor(Form? f, Cursor? z)
public void SetCursor(Control f, Cursor? z)
{
f?.Cursor = z;
if (OwnedCursor is not null && !ReferenceEquals(OwnedCursor.Cursor, z))
DisposeOwnedCursor();
f.Cursor = z;
Info.Cursor = z;
}
public void SetOwnedCursor(Control f, Bitmap bitmap)
{
DisposeOwnedCursor();
OwnedCursor = new BitmapCursor(bitmap);
SetCursor(f, OwnedCursor.Cursor);
}
/// <summary>
/// Resets the cursor for the specified form to the default cursor.
/// </summary>
/// <param name="sender">The form to reset the cursor for.</param>
public void ResetCursor(Form? sender)
public void ResetCursor(Control sender)
{
SetCursor(sender, Cursors.Default);
}
@ -50,13 +61,28 @@ public void ResetCursor(Form? sender)
/// </summary>
public void Initialize()
{
DisposeOwnedCursor();
Info = new SlotChangeInfo<Cursor, PictureBox>();
}
/// <summary>
/// Resets the drag manager's slot change info.
/// </summary>
public void Reset() => Info.Reset();
public void Reset()
{
DisposeOwnedCursor();
Info.Reset();
}
private void DisposeOwnedCursor()
{
if (OwnedCursor is null)
return;
OwnedCursor.Dispose();
OwnedCursor = null;
}
public void Dispose() => DisposeOwnedCursor();
/// <summary>
/// Gets or sets the mouse down position for drag detection.

View File

@ -106,6 +106,7 @@ private void FormLoadAddEvents()
mnu.RequestEditorSaveAs += MainMenuSave;
dragout.ContextMenuStrip = mnu.mnuL;
C_SAV.menu.RequestEditorLegality = DisplayLegalityReport;
components.Add(mnu);
}
public void LoadInitialFiles(StartupArguments args)
@ -1239,7 +1240,7 @@ private async void Dragout_MouseDown(object sender, MouseEventArgs e)
var pb = (PictureBox)sender;
if (pb.Image is Bitmap img)
C_SAV.M.Drag.Info.Cursor = Cursor = new Cursor(img.GetHicon());
C_SAV.M.Drag.SetOwnedCursor(pb, img);
DoDragDrop(new DataObject(DataFormats.FileDrop, new[] { newfile }), DragDropEffects.Copy);
}

View File

@ -28,6 +28,8 @@ protected override void Dispose(bool disposing)
/// </summary>
private void InitializeComponent()
{
components = new System.ComponentModel.Container();
Tip = new System.Windows.Forms.ToolTip(components);
TB_Text = new System.Windows.Forms.TextBox();
CB_Species = new System.Windows.Forms.ComboBox();
B_Cancel = new System.Windows.Forms.Button();
@ -47,6 +49,12 @@ private void InitializeComponent()
((System.ComponentModel.ISupportInitialize)NUD_Generation).BeginInit();
SuspendLayout();
//
// Tip
//
Tip.AutoPopDelay = 32767;
Tip.InitialDelay = 200;
Tip.IsBalloon = false;
//
// TB_Text
//
TB_Text.Location = new System.Drawing.Point(114, 14);
@ -262,5 +270,6 @@ private void InitializeComponent()
private System.Windows.Forms.Label L_Generation;
private System.Windows.Forms.Button B_ClearTrash;
private System.Windows.Forms.Label L_String;
private System.Windows.Forms.ToolTip Tip;
}
}

View File

@ -10,7 +10,6 @@ namespace PKHeX.WinForms;
public partial class TrashEditor : Form
{
private readonly IStringConverter Converter;
private readonly ToolTip Tip = new() { InitialDelay = 200, IsBalloon = false, AutoPopDelay = 32_767 };
private readonly List<NumericUpDown> Bytes = [];
public string FinalString { get; private set; }
public byte[] FinalBytes { get; private set; }
@ -93,8 +92,7 @@ private void AddCharEditing(Font f, EntityContext context)
l.Size = new Size(20, 20);
l.Click += (_, _) => { if (TB_Text.Text.Length < TB_Text.MaxLength) TB_Text.AppendText(l.Text); };
FLP_Characters.Controls.Add(l);
var tt = new ToolTip();
tt.SetToolTip(l, $"Insert {l.Text} (0x{c:X4})");
Tip.SetToolTip(l, $"Insert {l.Text} (0x{c:X4})");
}
}

View File

@ -52,6 +52,8 @@ private void GetContextMenu()
ContextMenuStrip mnu = new();
mnu.Items.Add(mnuHide);
mnu.Items.Add(mnuRestore);
components ??= new System.ComponentModel.Container();
components.Add(mnu);
dgData.ContextMenuStrip = mnu;
}

View File

@ -31,6 +31,7 @@ public SAV_FolderList(Action<SaveFile> openSaveFile)
var drives = Environment.GetLogicalDrives();
Paths = GetPathList(drives, backups);
components ??= new System.ComponentModel.Container();
dgDataRecent.ContextMenuStrip = GetContextMenu(dgDataRecent);
dgDataBackup.ContextMenuStrip = GetContextMenu(dgDataBackup);
dgDataRecent.Sorted += (_, _) => GetFilterText(dgDataRecent);
@ -111,7 +112,7 @@ private void AddButton(string name, string path)
};
FLP_Buttons.Controls.Add(button);
var hover = new ToolTip {AutoPopDelay = 30_000};
var hover = new ToolTip(components) {AutoPopDelay = 30_000};
button.MouseHover += (_, _) => hover.Show(path, button);
}
@ -202,6 +203,7 @@ private ContextMenuStrip GetContextMenu(DataGridView dgv)
mnu.Items.Add(mnuOpen);
mnu.Items.Add(mnuBrowseAt);
mnu.Items.Add(mnuDelete);
components.Add(mnu);
return mnu;
}

View File

@ -290,7 +290,7 @@ private static void ApplyWorkFilter(TableLayoutPanel panel, IReadOnlyList<(strin
panel.ResumeLayout();
}
private TextBox CreateSearchBox(Action<string> applyFilter)
private static TextBox CreateSearchBox(Action<string> applyFilter)
{
var box = new TextBox
{

View File

@ -332,7 +332,7 @@ private static void ApplyConstFilter(TableLayoutPanel panel, IReadOnlyList<(stri
panel.ResumeLayout();
}
private TextBox CreateSearchBox(Action<string> applyFilter)
private static TextBox CreateSearchBox(Action<string> applyFilter)
{
var box = new TextBox
{

View File

@ -334,7 +334,7 @@ private static void ApplyConstFilter(TableLayoutPanel panel, IReadOnlyList<(stri
panel.ResumeLayout();
}
private TextBox CreateSearchBox(Action<string> applyFilter)
private static TextBox CreateSearchBox(Action<string> applyFilter)
{
var box = new TextBox
{

View File

@ -250,7 +250,7 @@ private static void ApplyWorkFilter(TableLayoutPanel panel, IReadOnlyList<(strin
panel.ResumeLayout();
}
private TextBox CreateSearchBox(Action<string> applyFilter)
private static TextBox CreateSearchBox(Action<string> applyFilter)
{
var box = new TextBox
{