Skip to content

Regression: Bitmap finalized during Image.Save(Stream, ImageFormat) leaks native GDI+ memory #15213

Description

@avendel

.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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    🪲 bugProduct bug (most likely)💥 regression-releaseRegression from a public releaseuntriagedThe team needs to look at this issue in the next triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions