.NET version
.NET 10, but regression likely introduced in .NET 9. The problem occurs when referencing System.Drawing.Common version 9.0 or later. It is not related to runtime version.
Did it work in .NET Framework?
Not tested/verified
Did it work in any of the earlier releases of .NET Core or .NET 5+?
Yes, when referencing System.Drawing.Common version 8.0.
The problem might have been introduced with #10783 or #10784. Now, Save(Stream, ImageFormat) calls the internal CoreImageExtensions.Save, which passes the native pointer to GDI+ without keeping the image alive. The ImageCodecInfo overload calls GC.KeepAlive(this).
Issue description
Saving an image with Image.Save(Stream, ImageFormat) to a MemoryStream can leak native memory if nothing else references the image. The GDI+ bitmap is never freed, and a full GC with finalizers doesn't recover it. Disposing the image avoids the leak, and so does using the Save(stream, codec, null) overload instead.
A possible explanation: Save(Stream, ImageFormat) calls the internal CoreImageExtensions.Save, which passes the native pointer to GDI+ without keeping the image alive, while the ImageCodecInfo overload calls GC.KeepAlive(this). If the image is finalized while GDI+ is still saving it, GdipDisposeImage could fail, likely with ObjectBusy. Since Dispose ignores the status and clears the handle, the native bitmap would be orphaned.
Steps to reproduce
#:package System.Drawing.Common@10.0.12
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
long before = NativeMemory();
for (int i = 0; i < 200; i++) { SaveBitmap(); }
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Console.WriteLine($"Leaked: {(NativeMemory() - before) / 1024 / 1024} MB");
// Nothing keeps the bitmap alive during Save, so it can be finalized while GDI+ is still saving it.
static void SaveBitmap() => new Bitmap(1500, 1500).Save(new MemoryStream(), ImageFormat.Bmp);
static long NativeMemory() => Process.GetCurrentProcess().PrivateMemorySize64 - GC.GetGCMemoryInfo().TotalCommittedBytes;
Save the code above as SaveLeak.cs and run it on Windows with the .NET 10 SDK:
> dotnet run -c Release -p:TargetFramework=net10.0-windows SaveLeak.cs
Leaked: 277 MB
The reported amount varies between runs, but it is never close to zero.
Expected: close to 0 MB. After a full GC with finalizers, all bitmap memory is freed.
Actual: hundreds of MB of native memory are never freed.
Changing the first line to #:package System.Drawing.Common@8.0.31 gives Leaked: 2 MB on the same runtime.
Release mode is required in order to reproduce the issue.
.NET version
.NET 10, but regression likely introduced in .NET 9. The problem occurs when referencing System.Drawing.Common version 9.0 or later. It is not related to runtime version.
Did it work in .NET Framework?
Not tested/verified
Did it work in any of the earlier releases of .NET Core or .NET 5+?
Yes, when referencing System.Drawing.Common version 8.0.
The problem might have been introduced with #10783 or #10784. Now,
Save(Stream, ImageFormat)calls the internalCoreImageExtensions.Save, which passes the native pointer to GDI+ without keeping the image alive. The ImageCodecInfo overload callsGC.KeepAlive(this).Issue description
Saving an image with
Image.Save(Stream, ImageFormat)to aMemoryStreamcan leak native memory if nothing else references the image. The GDI+ bitmap is never freed, and a full GC with finalizers doesn't recover it. Disposing the image avoids the leak, and so does using theSave(stream, codec, null)overload instead.A possible explanation:
Save(Stream, ImageFormat)calls the internalCoreImageExtensions.Save, which passes the native pointer to GDI+ without keeping the image alive, while theImageCodecInfooverload callsGC.KeepAlive(this). If the image is finalized while GDI+ is still saving it,GdipDisposeImagecould fail, likely withObjectBusy. SinceDisposeignores the status and clears the handle, the native bitmap would be orphaned.Steps to reproduce
Save the code above as
SaveLeak.csand run it on Windows with the .NET 10 SDK:The reported amount varies between runs, but it is never close to zero.
Expected: close to 0 MB. After a full GC with finalizers, all bitmap memory is freed.
Actual: hundreds of MB of native memory are never freed.
Changing the first line to
#:package System.Drawing.Common@8.0.31givesLeaked: 2 MBon the same runtime.Release mode is required in order to reproduce the issue.