Prerequisites
Checks and verification limits:
ImageSharp version
4.1.2 (4.1.2+dee414097e4be7bb35ef0209cf96bd0b61a7133a)
Other ImageSharp packages and versions
None.
Environment (Operating system, version and so on)
Windows x64 (Microsoft Windows 10.0.26300).
.NET Framework version
.NET 10.0.12.
Description
When a PNG Identify or Decode is cancelled, the buffer that
PngDecoderCore.ReadChunkData rents from the configured MemoryAllocator can
be left undisposed.
ReadChunkData allocates the chunk buffer and then reads into it through
BufferedReadStream. That read begins with
CancellationToken.ThrowIfCancellationRequested(). If cancellation is observed
there, the exception leaves ReadChunkData before the buffer is returned, so
nothing ever disposes it. The callers dispose chunk data in a finally
(chunk.Data?.Dispose()), but that block only wraps the body of the
while (this.TryReadChunk(...)) loop. It never sees a buffer whose
TryReadChunk call threw.
The effect: MemoryDiagnostics.TotalUndisposedAllocationCount stays one above
its baseline after the cancelled operation. After the processing method returns
and the reproduction performs diagnostic GC, the library's
UndisposedAllocation event reports an undisposed buffer, with an allocation
stack in PngDecoderCore.TryReadChunk. The first-chance throw stack also
includes ReadChunkData.
This demonstrates missing explicit disposal, not that the memory necessarily
remains allocated forever after finalization. The diagnostic count does not
decrement on finalization. Only Release was run; Debug remains unverified.
Source at the v4.1.2 commit:
ReadChunkData allocates (L2486), then reads (L2488), with no try/finally:
|
[MethodImpl(InliningOptions.ShortMethod)] |
|
private IMemoryOwner<byte> ReadChunkData(int length) |
|
{ |
|
if (length == 0) |
|
{ |
|
return new BasicArrayBuffer<byte>([]); |
|
} |
|
|
|
// We rent the buffer here to return it afterwards in Decode() |
|
// We don't want to throw a degenerated memory exception here as we want to allow partial decoding |
|
// so limit the length. |
|
length = (int)Math.Min(length, this.currentStream.Length - this.currentStream.Position); |
|
IMemoryOwner<byte> buffer = this.configuration.MemoryAllocator.Allocate<byte>(length, AllocationOptions.Clean); |
|
|
|
this.currentStream.Read(buffer.GetSpan(), 0, length); |
|
|
|
return buffer; |
|
} |
TryReadChunk builds the chunk from it:
|
chunk = new PngChunk( |
|
length: (int)Math.Min(length, this.currentStream.Length - position), |
|
type: type, |
|
data: this.ReadChunkData(length)); |
|
|
|
this.ValidateChunk(chunk, buffer); |
- The
Decode loop and its dispose, L191 and L325:
|
while (this.TryReadChunk(buffer, out PngChunk chunk)) |
|
chunk.Data?.Dispose(); // Data is rented in ReadChunkData() |
- The
Identify loop and its dispose, L366 and L537:
|
while (this.TryReadChunk(buffer, out PngChunk chunk)) |
|
chunk.Data?.Dispose(); // Data is rented in ReadChunkData() |
BufferedReadStream.Read(Span<byte>) checks the token first (L178):
|
public override int Read(Span<byte> buffer) |
|
{ |
|
this.cancellationToken.ThrowIfCancellationRequested(); |
From the source, not exercised by the repro: in 4.1.2 TryReadChunk also
calls ReadChunkData for IDAT/fdAT chunks (it reads them to check the CRC),
so the same window exists for data chunks. The repro cancels at the first such
allocation in each phase.
How we found it: a test that cancels processing after 0, 5, 20 and 60 ms and
then asserts TotalUndisposedAllocationCount is back to its baseline failed
intermittently (expected 0, actual 1). The inline repro below makes that window
deterministic. It shows the mechanism; it does not show that every
intermittent failure we saw took this path.
Expected: the buffer is released on every path, including cancellation, so
the undisposed count returns to its baseline.
Steps to Reproduce
The inline repro below is a console app that references only ImageSharp 4.1.2 and
generates its own 2048×1536 PNG. Save the two files below into a repro
directory. Building requires the .NET 10 SDK and the normal ImageSharp 4 license
configuration; no license key is included. Run one case per process:
cd repro
dotnet build -c Release
dotnet run -c Release --no-build -- A # no cancellation
dotnet run -c Release --no-build -- B # token cancelled before the operation
dotnet run -c Release --no-build -- C # cancelled at the ReadChunkData allocation in Identify
dotnet run -c Release --no-build -- D # cancelled at the ReadChunkData allocation in Decode
To make the timing deterministic, the repro observes the internal
MemoryDiagnostics.MemoryAllocated event through reflection (diagnostics only,
nothing is patched). It cancels the token once, from inside the allocation
whose stack contains PngDecoderCore.ReadChunkData in the chosen phase, so the
next statement — the stream read — throws.
Result of one run per case:
| Case |
Outcome |
Undisposed count, before any GC |
Finalizer reports |
| A |
completed (2048×1536) |
+0 |
0 |
| B |
TaskCanceledException |
+0 |
0 |
| C |
TaskCanceledException |
+1 |
1 |
| D |
TaskCanceledException |
+1 |
1 |
Throw site in C (D is the same under Decode):
at System.Threading.CancellationToken.ThrowIfCancellationRequested
at SixLabors.ImageSharp.IO.BufferedReadStream.Read
at SixLabors.ImageSharp.BufferedReadStreamExtensions.Read
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.ReadChunkData
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.TryReadChunk
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.Identify
Images
None needed: the repro generates its input.
Complete reproduction
repro/PngCancellationLeakRepro.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="4.1.2" />
</ItemGroup>
</Project>
repro/Program.cs
// Minimal reproduction for SixLabors.ImageSharp 4.1.2: the buffer rented in
// PngDecoderCore.ReadChunkData is never disposed when the stream read that
// follows the allocation observes cancellation.
//
// The cancellation is made deterministic by cancelling from inside that
// allocation. The internal MemoryDiagnostics.MemoryAllocated event is observed
// through reflection (diagnostics only; nothing in ImageSharp is patched). On
// each allocation the stack is checked for PngDecoderCore.ReadChunkData inside
// the intended phase (Identify or Decode), and the token is cancelled once. The
// next statement in ReadChunkData is the BufferedReadStream read, which throws.
//
// Usage, one case per process:
// dotnet run -c Release -- A no cancellation
// dotnet run -c Release -- B token cancelled before the operation starts
// dotnet run -c Release -- C cancelled at the ReadChunkData allocation in Identify
// dotnet run -c Release -- D cancelled at the ReadChunkData allocation in Decode
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Diagnostics;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.Memory;
using SixLabors.ImageSharp.PixelFormats;
var caseId = args.Length == 1 ? args[0].ToUpperInvariant() : string.Empty;
if (caseId is not ("A" or "B" or "C" or "D"))
{
Console.Error.WriteLine("usage: dotnet run -c Release -- A|B|C|D");
return 2;
}
Out("case", caseId switch
{
"A" => "A - no cancellation",
"B" => "B - token cancelled before the operation starts",
"C" => "C - cancelled at the ReadChunkData allocation in PNG Identify",
_ => "D - cancelled at the ReadChunkData allocation in PNG Decode"
});
Out("runtime", RuntimeInformation.FrameworkDescription);
Out("os", RuntimeInformation.OSDescription);
Out("arch", RuntimeInformation.ProcessArchitecture);
Out("imagesharp", typeof(Image).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion);
var memoryAllocated = typeof(MemoryDiagnostics).GetEvent(
"MemoryAllocated",
BindingFlags.NonPublic | BindingFlags.Static);
if (memoryAllocated is null)
{
Out("result", "cannot run: internal MemoryDiagnostics.MemoryAllocated not found");
return 3;
}
// Input: a plain 2048x1536 RGBA PNG generated here; no external file.
var png = CreatePng(2048, 1536);
Out("input", $"generated PNG 2048x1536, {png.Length} bytes");
// Settle before taking the baseline.
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
// The library's own finalizer-based report of undisposed allocations. Subscribing
// also makes it record an allocation stack for each allocation from here on.
var undisposedReports = new ConcurrentQueue<string>();
MemoryDiagnostics.UndisposedAllocation += stack => undisposedReports.Enqueue(stack);
var baseline = MemoryDiagnostics.TotalUndisposedAllocationCount;
Out("baseline.TotalUndisposedAllocationCount", baseline);
using var cancellation = new CancellationTokenSource();
var targetPhase = caseId switch { "C" => "Identify", "D" => "Decode", _ => null };
var otherPhase = caseId switch { "C" => "Decode", "D" => "Identify", _ => null };
var fired = 0;
string? hookStack = null;
var onAllocated = new Action(() =>
{
if (targetPhase is null || Volatile.Read(ref fired) != 0)
{
return;
}
var frames = new StackTrace(fNeedFileInfo: false).GetFrames();
bool inReadChunkData = false, inTarget = false, inOther = false;
foreach (var frame in frames)
{
var method = frame.GetMethod();
if (method?.DeclaringType?.Name != "PngDecoderCore")
{
continue;
}
if (method.Name == "ReadChunkData")
{
inReadChunkData = true;
}
else if (method.Name == targetPhase)
{
inTarget = true;
}
else if (method.Name == otherPhase)
{
inOther = true;
}
}
if (!inReadChunkData || !inTarget || inOther || Interlocked.CompareExchange(ref fired, 1, 0) != 0)
{
return;
}
hookStack = Frames(frames.Skip(1), 10);
// The allocation has happened; the read that follows it has not.
cancellation.Cancel();
});
// The stack at the point where the cancellation is thrown (an awaited
// exception no longer carries it).
string? throwSite = null;
AppDomain.CurrentDomain.FirstChanceException += (_, e) =>
{
if (e.Exception is OperationCanceledException && throwSite is null)
{
// From the token's throw helper when there is one; otherwise from the
// first frame after this handler and the runtime's dispatch.
var frames = new StackTrace(fNeedFileInfo: false).GetFrames().Skip(1).ToArray();
var fromToken = frames.SkipWhile(f =>
f.GetMethod()?.DeclaringType?.FullName?.StartsWith("System.Threading.CancellationToken", StringComparison.Ordinal) != true).ToArray();
throwSite = Frames(
fromToken.Length > 0
? fromToken
: frames.SkipWhile(f => f.GetMethod()?.DeclaringType?.FullName?.StartsWith("System.Runtime.EH", StringComparison.Ordinal) == true),
8);
}
};
if (targetPhase is not null)
{
memoryAllocated.GetAddMethod(nonPublic: true)!.Invoke(null, [onAllocated]);
}
if (caseId == "B")
{
cancellation.Cancel();
}
var (outcome, awaitStack, immediate) = await RunAsync(png, cancellation.Token);
if (targetPhase is not null)
{
memoryAllocated.GetRemoveMethod(nonPublic: true)!.Invoke(null, [onAllocated]);
}
Out("trigger", targetPhase is null
? "n/a"
: fired == 1
? $"fired once, at the ReadChunkData allocation inside PngDecoderCore.{targetPhase}"
: "DID NOT FIRE - the intended allocation was not identified, so this run shows nothing");
if (hookStack is not null)
{
Console.WriteLine("trigger.allocationStack=" + Environment.NewLine + hookStack);
}
Out("outcome", outcome);
if (awaitStack is not null)
{
Console.WriteLine("outcome.awaitStack=" + Environment.NewLine + awaitStack);
}
Console.WriteLine("cancellation.throwSite=" + (throwSite is null ? "none" : Environment.NewLine + throwSite));
Out("immediate.TotalUndisposedAllocationCount", immediate);
Out("immediate.delta", immediate - baseline);
// Diagnostic only, after the measurement above: once the configuration that
// owns the allocator is unreachable, lets the library's finalizer report any
// allocation that was never disposed.
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
GC.WaitForPendingFinalizers();
Out("afterGc.TotalUndisposedAllocationCount.delta", MemoryDiagnostics.TotalUndisposedAllocationCount - baseline);
Out("undisposedAllocationReports", undisposedReports.Count);
var index = 0;
foreach (var report in undisposedReports)
{
index++;
var lines = report
.Split('\n')
.Select(line => Regex.Replace(line.TrimEnd('\r'), @" in .+?:line \d+$", string.Empty))
.Where(line => line.Contains(" at ", StringComparison.Ordinal))
.Take(9);
Console.WriteLine($"undisposedAllocationReport[{index}].allocationStack=" + Environment.NewLine + string.Join(Environment.NewLine, lines));
}
return 0;
// Identify, then decode, with a dedicated configuration that is unreachable once
// this returns. The undisposed count is read here, before any GC.
[MethodImpl(MethodImplOptions.NoInlining)]
static async Task<(string Outcome, string? AwaitStack, int Immediate)> RunAsync(byte[] png, CancellationToken token)
{
var configuration = new Configuration(new PngConfigurationModule())
{
MemoryAllocator = MemoryAllocator.Create()
};
var options = new DecoderOptions { Configuration = configuration };
string outcome;
string? awaitStack = null;
try
{
using var stream = new MemoryStream(png, writable: false);
var info = await Image.IdentifyAsync(options, stream, token);
stream.Position = 0;
using var image = await Image.LoadAsync<Rgba32>(options, stream, token);
outcome = $"completed: identified {info.Width}x{info.Height}, decoded {image.Width}x{image.Height}";
}
catch (Exception e)
{
outcome = $"{e.GetType().FullName}: {e.Message}";
awaitStack = Frames(new StackTrace(e, fNeedFileInfo: false).GetFrames(), 6);
}
var immediate = MemoryDiagnostics.TotalUndisposedAllocationCount;
GC.KeepAlive(configuration);
return (outcome, awaitStack, immediate);
}
static byte[] CreatePng(int width, int height)
{
using var image = new Image<Rgba32>(width, height);
image.ProcessPixelRows(accessor =>
{
for (var y = 0; y < accessor.Height; y++)
{
var row = accessor.GetRowSpan(y);
for (var x = 0; x < row.Length; x++)
{
row[x] = new Rgba32((byte)(x * 255 / width), (byte)(y * 255 / height), (byte)((x + y) & 0xFF), 255);
}
}
});
using var output = new MemoryStream();
image.SaveAsPng(output);
return output.ToArray();
}
static string Frames(IEnumerable<StackFrame> frames, int take)
{
return string.Join(Environment.NewLine, frames.Take(take).Select(frame =>
{
var method = frame.GetMethod();
return $" at {method?.DeclaringType?.FullName}.{method?.Name}";
}));
}
static void Out(string key, object? value) => Console.WriteLine($"{key}={value}");
Recorded output
These are the supplied Windows Release run outputs, one fresh process per case.
Case A
case=A - no cancellation
runtime=.NET 10.0.12
os=Microsoft Windows 10.0.26300
arch=X64
imagesharp=4.1.2+dee414097e4be7bb35ef0209cf96bd0b61a7133a
input=generated PNG 2048x1536, 16856 bytes
baseline.TotalUndisposedAllocationCount=0
trigger=n/a
outcome=completed: identified 2048x1536, decoded 2048x1536
cancellation.throwSite=none
immediate.TotalUndisposedAllocationCount=0
immediate.delta=0
afterGc.TotalUndisposedAllocationCount.delta=0
undisposedAllocationReports=0
Case B
case=B - token cancelled before the operation starts
runtime=.NET 10.0.12
os=Microsoft Windows 10.0.26300
arch=X64
imagesharp=4.1.2+dee414097e4be7bb35ef0209cf96bd0b61a7133a
input=generated PNG 2048x1536, 16856 bytes
baseline.TotalUndisposedAllocationCount=0
trigger=n/a
outcome=System.Threading.Tasks.TaskCanceledException: A task was canceled.
outcome.awaitStack=
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification
at System.Threading.Tasks.ValueTask`1.get_Result
at System.Runtime.CompilerServices.ValueTaskAwaiter`1.GetResult
at SixLabors.ImageSharp.Image+<InternalDetectFormatAsync>d__44.MoveNext
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw
cancellation.throwSite=
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification
at System.Threading.Tasks.ValueTask`1.get_Result
at System.Runtime.CompilerServices.ValueTaskAwaiter`1.GetResult
at SixLabors.ImageSharp.Image+<InternalDetectFormatAsync>d__44.MoveNext
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start
at System.Runtime.CompilerServices.AsyncValueTaskMethodBuilder`1.Start
at SixLabors.ImageSharp.Image.InternalDetectFormatAsync
immediate.TotalUndisposedAllocationCount=0
immediate.delta=0
afterGc.TotalUndisposedAllocationCount.delta=0
undisposedAllocationReports=0
Case C
case=C - cancelled at the ReadChunkData allocation in PNG Identify
runtime=.NET 10.0.12
os=Microsoft Windows 10.0.26300
arch=X64
imagesharp=4.1.2+dee414097e4be7bb35ef0209cf96bd0b61a7133a
input=generated PNG 2048x1536, 16856 bytes
baseline.TotalUndisposedAllocationCount=0
trigger=fired once, at the ReadChunkData allocation inside PngDecoderCore.Identify
trigger.allocationStack=
at SixLabors.ImageSharp.Diagnostics.MemoryDiagnostics.IncrementTotalUndisposedAllocationCount
at SixLabors.ImageSharp.Memory.Internals.RefCountedMemoryLifetimeGuard..ctor
at SixLabors.ImageSharp.Memory.Internals.SharedArrayPoolBuffer`1+LifetimeGuard..ctor
at SixLabors.ImageSharp.Memory.Internals.SharedArrayPoolBuffer`1..ctor
at SixLabors.ImageSharp.Memory.UniformUnmanagedMemoryPoolMemoryAllocator.AllocateCore
at SixLabors.ImageSharp.Memory.MemoryAllocator.Allocate
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.ReadChunkData
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.TryReadChunk
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.Identify
at SixLabors.ImageSharp.Formats.ImageDecoderCore.Identify
outcome=System.Threading.Tasks.TaskCanceledException: A task was canceled.
outcome.awaitStack=
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification
at System.Runtime.CompilerServices.ConfiguredTaskAwaitable`1+ConfiguredTaskAwaiter.GetResult
at SixLabors.ImageSharp.Formats.ImageDecoder+<IdentifyAsync>d__5.MoveNext
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess
cancellation.throwSite=
at System.Threading.CancellationToken.ThrowOperationCanceledException
at System.Threading.CancellationToken.ThrowIfCancellationRequested
at SixLabors.ImageSharp.IO.BufferedReadStream.Read
at SixLabors.ImageSharp.BufferedReadStreamExtensions.Read
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.ReadChunkData
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.TryReadChunk
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.Identify
at SixLabors.ImageSharp.Formats.ImageDecoderCore.Identify
immediate.TotalUndisposedAllocationCount=1
immediate.delta=1
afterGc.TotalUndisposedAllocationCount.delta=1
undisposedAllocationReports=1
undisposedAllocationReport[1].allocationStack=
at System.Environment.get_StackTrace()
at SixLabors.ImageSharp.Memory.Internals.RefCountedMemoryLifetimeGuard..ctor()
at SixLabors.ImageSharp.Memory.Internals.SharedArrayPoolBuffer`1.LifetimeGuard..ctor(Byte[] array)
at SixLabors.ImageSharp.Memory.Internals.SharedArrayPoolBuffer`1..ctor(Int32 lengthInElements)
at SixLabors.ImageSharp.Memory.UniformUnmanagedMemoryPoolMemoryAllocator.AllocateCore[T](Int32 length, AllocationOptions options)
at SixLabors.ImageSharp.Memory.MemoryAllocator.Allocate[T](Int32 length, AllocationOptions options)
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.TryReadChunk(Span`1 buffer, PngChunk& chunk)
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.Identify(BufferedReadStream stream, CancellationToken cancellationToken)
at SixLabors.ImageSharp.Formats.ImageDecoderCore.Identify(Configuration configuration, Stream stream, CancellationToken cancellationToken)
Case D
case=D - cancelled at the ReadChunkData allocation in PNG Decode
runtime=.NET 10.0.12
os=Microsoft Windows 10.0.26300
arch=X64
imagesharp=4.1.2+dee414097e4be7bb35ef0209cf96bd0b61a7133a
input=generated PNG 2048x1536, 16856 bytes
baseline.TotalUndisposedAllocationCount=0
trigger=fired once, at the ReadChunkData allocation inside PngDecoderCore.Decode
trigger.allocationStack=
at SixLabors.ImageSharp.Diagnostics.MemoryDiagnostics.IncrementTotalUndisposedAllocationCount
at SixLabors.ImageSharp.Memory.Internals.RefCountedMemoryLifetimeGuard..ctor
at SixLabors.ImageSharp.Memory.Internals.SharedArrayPoolBuffer`1+LifetimeGuard..ctor
at SixLabors.ImageSharp.Memory.Internals.SharedArrayPoolBuffer`1..ctor
at SixLabors.ImageSharp.Memory.UniformUnmanagedMemoryPoolMemoryAllocator.AllocateCore
at SixLabors.ImageSharp.Memory.MemoryAllocator.Allocate
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.ReadChunkData
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.TryReadChunk
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.Decode
at SixLabors.ImageSharp.Formats.ImageDecoderCore.Decode
outcome=System.Threading.Tasks.TaskCanceledException: A task was canceled.
outcome.awaitStack=
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess
at System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotification
at System.Runtime.CompilerServices.ConfiguredTaskAwaitable`1+ConfiguredTaskAwaiter.GetResult
at SixLabors.ImageSharp.Formats.ImageDecoder+<DecodeAsync>d__2`1.MoveNext
at System.Runtime.ExceptionServices.ExceptionDispatchInfo.Throw
at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess
cancellation.throwSite=
at System.Threading.CancellationToken.ThrowOperationCanceledException
at System.Threading.CancellationToken.ThrowIfCancellationRequested
at SixLabors.ImageSharp.IO.BufferedReadStream.Read
at SixLabors.ImageSharp.BufferedReadStreamExtensions.Read
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.ReadChunkData
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.TryReadChunk
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.Decode
at SixLabors.ImageSharp.Formats.ImageDecoderCore.Decode
immediate.TotalUndisposedAllocationCount=1
immediate.delta=1
afterGc.TotalUndisposedAllocationCount.delta=1
undisposedAllocationReports=1
undisposedAllocationReport[1].allocationStack=
at System.Environment.get_StackTrace()
at SixLabors.ImageSharp.Memory.Internals.RefCountedMemoryLifetimeGuard..ctor()
at SixLabors.ImageSharp.Memory.Internals.SharedArrayPoolBuffer`1.LifetimeGuard..ctor(Byte[] array)
at SixLabors.ImageSharp.Memory.Internals.SharedArrayPoolBuffer`1..ctor(Int32 lengthInElements)
at SixLabors.ImageSharp.Memory.UniformUnmanagedMemoryPoolMemoryAllocator.AllocateCore[T](Int32 length, AllocationOptions options)
at SixLabors.ImageSharp.Memory.MemoryAllocator.Allocate[T](Int32 length, AllocationOptions options)
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.TryReadChunk(Span`1 buffer, PngChunk& chunk)
at SixLabors.ImageSharp.Formats.Png.PngDecoderCore.Decode[TPixel](BufferedReadStream stream, CancellationToken cancellationToken)
at SixLabors.ImageSharp.Formats.ImageDecoderCore.Decode[TPixel](Configuration configuration, Stream stream, CancellationToken cancellationToken)
Prerequisites
Checks and verification limits:
ReadChunkDataand cancellation/memory searches found no matching report.ImageSharp version
4.1.2 (
4.1.2+dee414097e4be7bb35ef0209cf96bd0b61a7133a)Other ImageSharp packages and versions
None.
Environment (Operating system, version and so on)
Windows x64 (
Microsoft Windows 10.0.26300)..NET Framework version
.NET 10.0.12.
Description
When a PNG
IdentifyorDecodeis cancelled, the buffer thatPngDecoderCore.ReadChunkDatarents from the configuredMemoryAllocatorcanbe left undisposed.
ReadChunkDataallocates the chunk buffer and then reads into it throughBufferedReadStream. That read begins withCancellationToken.ThrowIfCancellationRequested(). If cancellation is observedthere, the exception leaves
ReadChunkDatabefore the buffer is returned, sonothing ever disposes it. The callers dispose chunk data in a
finally(
chunk.Data?.Dispose()), but that block only wraps the body of thewhile (this.TryReadChunk(...))loop. It never sees a buffer whoseTryReadChunkcall threw.The effect:
MemoryDiagnostics.TotalUndisposedAllocationCountstays one aboveits baseline after the cancelled operation. After the processing method returns
and the reproduction performs diagnostic GC, the library's
UndisposedAllocationevent reports an undisposed buffer, with an allocationstack in
PngDecoderCore.TryReadChunk. The first-chance throw stack alsoincludes
ReadChunkData.This demonstrates missing explicit disposal, not that the memory necessarily
remains allocated forever after finalization. The diagnostic count does not
decrement on finalization. Only Release was run; Debug remains unverified.
Source at the v4.1.2 commit:
ReadChunkDataallocates (L2486), then reads (L2488), with notry/finally:ImageSharp/src/ImageSharp/Formats/Png/PngDecoderCore.cs
Lines 2474 to 2491 in dee4140
TryReadChunkbuilds the chunk from it:ImageSharp/src/ImageSharp/Formats/Png/PngDecoderCore.cs
Lines 2372 to 2377 in dee4140
Decodeloop and its dispose, L191 and L325:ImageSharp/src/ImageSharp/Formats/Png/PngDecoderCore.cs
Line 191 in dee4140
ImageSharp/src/ImageSharp/Formats/Png/PngDecoderCore.cs
Line 325 in dee4140
Identifyloop and its dispose, L366 and L537:ImageSharp/src/ImageSharp/Formats/Png/PngDecoderCore.cs
Line 366 in dee4140
ImageSharp/src/ImageSharp/Formats/Png/PngDecoderCore.cs
Line 537 in dee4140
BufferedReadStream.Read(Span<byte>)checks the token first (L178):ImageSharp/src/ImageSharp/IO/BufferedReadStream.cs
Lines 176 to 178 in dee4140
From the source, not exercised by the repro: in 4.1.2
TryReadChunkalsocalls
ReadChunkDataforIDAT/fdATchunks (it reads them to check the CRC),so the same window exists for data chunks. The repro cancels at the first such
allocation in each phase.
How we found it: a test that cancels processing after 0, 5, 20 and 60 ms and
then asserts
TotalUndisposedAllocationCountis back to its baseline failedintermittently (expected 0, actual 1). The inline repro below makes that window
deterministic. It shows the mechanism; it does not show that every
intermittent failure we saw took this path.
Expected: the buffer is released on every path, including cancellation, so
the undisposed count returns to its baseline.
Steps to Reproduce
The inline repro below is a console app that references only ImageSharp 4.1.2 and
generates its own 2048×1536 PNG. Save the two files below into a
reprodirectory. Building requires the .NET 10 SDK and the normal ImageSharp 4 license
configuration; no license key is included. Run one case per process:
To make the timing deterministic, the repro observes the internal
MemoryDiagnostics.MemoryAllocatedevent through reflection (diagnostics only,nothing is patched). It cancels the token once, from inside the allocation
whose stack contains
PngDecoderCore.ReadChunkDatain the chosen phase, so thenext statement — the stream read — throws.
Result of one run per case:
TaskCanceledExceptionTaskCanceledExceptionTaskCanceledExceptionThrow site in C (D is the same under
Decode):Images
None needed: the repro generates its input.
Complete reproduction
repro/PngCancellationLeakRepro.csproj
repro/Program.cs
Recorded output
These are the supplied Windows Release run outputs, one fresh process per case.
Case A
Case B
Case C
Case D