PNG decoder: chunk buffer is still not disposed when cancellation is observed just after the chunk-data read (follow-up to #3202)
Los mantenedores suelen responder en 1 día
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 4/5
- Tiempo estimado
- 1-2 días
- Aptitud para principiantes
- 30/100
Línea de trabajo
Start with TryReadChunk in src/ImageSharp/Formats/Png/PngDecoderCore.cs (around L2371-2386), then read ValidateChunk and ReadChunkCrc, and the Position setter and Read checks in src/ImageSharp/IO/BufferedReadStream.cs. Run the repro in Release for cases B and C, which should show an undisposed count of +0 once fixed. Done means the rented chunk buffer is released on every path, including the CRC-mismatch path that ValidateChunk already disposes.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
Prerequisites
- I have written a descriptive issue title
- I have verified that I am running the latest version of ImageSharp — 4.1.3, checked on 2026-10-10
- I have verified if the problem exist in both DEBUG and RELEASE mode — both builds of the repro were run; the output is identical apart from the
build=line - I have searched open and closed issues — searches for
ReadChunkData,TryReadChunk,ValidateChunk,ReadChunkCrcand for cancellation / undisposed-allocation reports found only #3202, which is closed.
ImageSharp version
4.1.3 (4.1.3+751037d0c6384c97e77ee1abd86e2203ae8dbd95)
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
This is a follow-up to #3202.
#3203 (backported to 4.1.x as #3209 and released in 4.1.3) put a try/catch
around the stream read in PngDecoderCore.ReadChunkData. That path is fixed
in 4.1.3, and the repro below confirms it (case A).
The rented buffer is still unowned for a few more statements after
ReadChunkData returns. TryReadChunk builds the chunk from it, validates the
CRC, and for IDAT/fdAT moves the stream back to the start of the data —
all before it hands the chunk to the loop whose finally disposes
chunk.Data. Two of those statements check the cancellation token:
ValidateChunk→ReadChunkCrc→BufferedReadStream.Read, which begins
withCancellationToken.ThrowIfCancellationRequested().this.currentStream.Position = positionforIDAT/fdAT: the
BufferedReadStream.Positionsetter makes the same check.
If a cancellation is first observed at either of them, the exception leaves
TryReadChunk and nothing disposes the buffer. ValidateChunk disposes it
only when the CRC does not match.
The effect is the one #3202 described:
MemoryDiagnostics.TotalUndisposedAllocationCount stays one above its
baseline after the cancelled operation. As there, this demonstrates missing
explicit disposal, not that the memory necessarily stays allocated after
finalization.
Source at the v4.1.3 commit:
TryReadChunk: the buffer is rented (L2375), the chunk is validated (L2377),
the position is restored forIDAT/fdAT(L2383), and only then does it
return:
https://github.com/SixLabors/ImageSharp/blob/751037d0c6384c97e77ee1abd86e2203ae8dbd95/src/ImageSharp/Formats/Png/PngDecoderCore.cs#L2371-L2386ReadChunkData, with the guard from #3203 around the read only:
https://github.com/SixLabors/ImageSharp/blob/751037d0c6384c97e77ee1abd86e2203ae8dbd95/src/ImageSharp/Formats/Png/PngDecoderCore.cs#L2475-L2501ValidateChunkreads the CRC first (L2422) and disposes only on a mismatch
(L2437);ReadChunkCrcis the read (L2451):
https://github.com/SixLabors/ImageSharp/blob/751037d0c6384c97e77ee1abd86e2203ae8dbd95/src/ImageSharp/Formats/Png/PngDecoderCore.cs#L2420-L2457- The
Decodeloop and its dispose, L191 and L325; theIdentifyloop and its
dispose, L366 and L537 (same file). BufferedReadStream.Read(Span<byte>)checks the token first (L178), and so
does thePositionsetter (L98):
https://github.com/SixLabors/ImageSharp/blob/751037d0c6384c97e77ee1abd86e2203ae8dbd95/src/ImageSharp/IO/BufferedReadStream.cs#L95-L98
https://github.com/SixLabors/ImageSharp/blob/751037d0c6384c97e77ee1abd86e2203ae8dbd95/src/ImageSharp/IO/BufferedReadStream.cs#L176-L178
From the source, not exercised by the repro: the first window (the CRC read)
does not depend on the chunk type, and TryReadChunk has other callers
(L2192, L2225, L2250). The window is inside TryReadChunk itself, so it should
not depend on the caller. The repro cancels at the first IDAT chunk only.
How this was noticed: while verifying the 4.1.3 fix. An application-level
harness that cancels PNG processing at random points still ended with the
count one above its baseline in a small share of runs on 4.1.3. That harness is
not part of this report, and those runs are mentioned only as the reason for
looking. The repro below makes the window deterministic. It shows the
mechanism; it does not show that every such run took one of these two paths.
A guard in TryReadChunk covering the statements between ReadChunkData and
the return would close both windows. Note that ValidateChunk already disposes
the buffer on a CRC mismatch, so such a guard has to allow for that. I have not
tested a patch.
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.3
and generates its own 256×256 PNG. It runs with the default configuration, with
no timer and no repetition. Only public ImageSharp APIs are used. Reflection is
limited to diagnostic version and stack information; it is not used to access
ImageSharp internals. 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.
cd repro
dotnet run -c Release
To compare with the previous release:
dotnet run -c Release -p:ImageSharpVersion=4.1.2
How the timing is made deterministic: the input is a MemoryStream subclass,
which the decoder reads in place. It cancels the token once, from inside one
chosen call, and lets that call complete. The decoder then observes the
cancellation at its next token check. The first IDAT chunk (65,535 bytes) is
larger than the decoder's 8,096-byte stream buffer, so its data is read in a
single call to the input stream and the stream buffer is refilled for the CRC;
the repro checks that precondition and refuses to run without it.
Result, one process per ImageSharp version. Each case runs once under
Image.IdentifyAsync and once under Image.LoadAsync<Rgba32>; both gave the
same count in every case.
| Case | The token is cancelled in | The decoder observes it at | 4.1.2 | 4.1.3 |
|---|---|---|---|---|
| A | the read of the first IDAT chunk's data |
BufferedReadStream.set_Position, inside the read in ReadChunkData |
+1 | +0 (fixed by #3203) |
| B | the seek that follows that read | the entry of BufferedReadStream.Read ← ReadChunkCrc ← ValidateChunk ← TryReadChunk |
+1 | +1 |
| C | the read that fetches that chunk's CRC | BufferedReadStream.set_Position ← TryReadChunk |
+1 | +1 |
The "observes it at" column is the throw site the repro records from the
first-chance exception; the full lines are in the recorded output.
Images
None needed: the repro generates its input.
Complete reproduction
repro/PngCancellationAfterChunkRead.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- To compare with the previous release: -p:ImageSharpVersion=4.1.2 -->
<ImageSharpVersion Condition="'$(ImageSharpVersion)' == ''">4.1.3</ImageSharpVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="$(ImageSharpVersion)" />
</ItemGroup>
</Project>
repro/Program.cs
// Reproduction for SixLabors.ImageSharp 4.1.3: a PNG chunk buffer is still left
// undisposed when a cancellation is first observed just AFTER the chunk-data
// read that #3203 / #3209 guarded.
//
// No timer and no repetition. The input stream cancels the token itself, at one
// chosen call, and the decoder observes it at its next check:
//
// A during the read of the first IDAT chunk's data
// -> observed inside that read (the path #3203 fixed)
// B in the seek that follows that read
// -> observed at the entry of the CRC read (ValidateChunk)
// C during the read that fetches that chunk's CRC
// -> observed when TryReadChunk moves the stream back to the IDAT data
//
// Each case runs once under Image.IdentifyAsync and once under Image.LoadAsync,
// with the default configuration.
//
// Only public ImageSharp APIs are used. Reflection is limited to diagnostic
// version and stack information; it is not used to access ImageSharp internals.
//
// dotnet run -c Release
using System.Buffers.Binary;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.InteropServices;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Diagnostics;
using SixLabors.ImageSharp.PixelFormats;
Console.WriteLine($"imagesharp={typeof(Image).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion}");
Console.WriteLine($"runtime={RuntimeInformation.FrameworkDescription}");
Console.WriteLine($"os={RuntimeInformation.OSDescription} {RuntimeInformation.ProcessArchitecture}");
#if DEBUG
Console.WriteLine("build=Debug");
#else
Console.WriteLine("build=Release");
#endif
byte[] png = CreatePng(256, 256);
(int dataOffset, int dataLength) = FirstIdat(png);
int streamBuffer = Configuration.Default.StreamProcessingBufferSize;
Console.WriteLine($"input=generated PNG 256x256, {png.Length} bytes; first IDAT data at offset {dataOffset}, {dataLength} bytes; decoder stream buffer {streamBuffer} bytes");
if (dataLength <= streamBuffer)
{
// The decoder reads a chunk in one call to the input stream only when the
// chunk is larger than its own buffer; the cases below rely on that.
Console.WriteLine("result=cannot run: the first IDAT chunk is not larger than the decoder's stream buffer");
return 2;
}
// Where the cancellation is thrown. An awaited exception no longer carries it.
string? observedAt = null;
AppDomain.CurrentDomain.FirstChanceException += (_, e) =>
{
if (e.Exception is OperationCanceledException && observedAt is null)
{
observedAt = string.Join(
" < ",
new StackTrace(fNeedFileInfo: false).GetFrames()
.Select(frame => frame.GetMethod())
.Where(method => method?.DeclaringType?.FullName?.StartsWith("SixLabors.ImageSharp", StringComparison.Ordinal) == true)
.Take(6)
.Select(method => $"{method!.DeclaringType!.Name}.{method.Name}"));
}
};
int keptAfterTheGuardedRead = 0;
foreach (CancelAt cancelAt in Enum.GetValues<CancelAt>())
{
foreach (bool decode in new[] { false, true })
{
using CancellationTokenSource cancellation = new();
using CancellingStream stream = new(png, dataOffset, dataLength, cancelAt, cancellation);
observedAt = null;
int before = MemoryDiagnostics.TotalUndisposedAllocationCount;
string outcome;
try
{
if (decode)
{
using Image<Rgba32> image = await Image.LoadAsync<Rgba32>(stream, cancellation.Token);
}
else
{
await Image.IdentifyAsync(stream, cancellation.Token);
}
outcome = "completed";
}
catch (OperationCanceledException e)
{
outcome = e.GetType().Name;
}
int kept = MemoryDiagnostics.TotalUndisposedAllocationCount - before;
if (cancelAt != CancelAt.DataRead)
{
keptAfterTheGuardedRead += kept;
}
Console.WriteLine();
Console.WriteLine($"case={Label(cancelAt)} api={(decode ? "Image.LoadAsync<Rgba32>" : "Image.IdentifyAsync")}");
Console.WriteLine($" cancelled in : {stream.CancelledIn ?? "NOT CANCELLED - this case shows nothing"}");
Console.WriteLine($" outcome : {outcome}");
Console.WriteLine($" observed at : {observedAt ?? "none"}");
Console.WriteLine($" undisposed count : {kept:+0;-0;+0}");
}
}
Console.WriteLine();
Console.WriteLine(keptAfterTheGuardedRead > 0
? $"result=REPRODUCED: {keptAfterTheGuardedRead} buffer(s) left undisposed in cases B and C"
: "result=not reproduced: cases B and C returned every buffer");
return keptAfterTheGuardedRead > 0 ? 1 : 0;
static string Label(CancelAt cancelAt) => cancelAt switch
{
CancelAt.DataRead => "A (during the chunk-data read)",
CancelAt.SeekAfterDataRead => "B (in the seek after the chunk-data read)",
_ => "C (during the CRC read)",
};
// Noise, so the image data does not fit in one small IDAT chunk.
static byte[] CreatePng(int width, int height)
{
Random random = new(7);
using Image<Rgba32> image = new(width, height);
image.ProcessPixelRows(accessor =>
{
for (int y = 0; y < accessor.Height; y++)
{
foreach (ref Rgba32 pixel in accessor.GetRowSpan(y))
{
pixel = new Rgba32((byte)random.Next(256), (byte)random.Next(256), (byte)random.Next(256), 255);
}
}
});
using MemoryStream output = new();
image.SaveAsPng(output);
return output.ToArray();
}
// Where the first IDAT chunk's data starts, and how long it is.
static (int Offset, int Length) FirstIdat(byte[] png)
{
for (int at = 8; at + 8 <= png.Length;)
{
int length = (int)BinaryPrimitives.ReadUInt32BigEndian(png.AsSpan(at));
if (png.AsSpan(at + 4, 4).SequenceEqual("IDAT"u8))
{
return (at + 8, length);
}
at += 4 + 4 + length + 4; // length, type, data, CRC
}
throw new InvalidOperationException("The PNG holds no IDAT chunk.");
}
enum CancelAt
{
DataRead,
SeekAfterDataRead,
CrcRead,
}
// A MemoryStream, because that is what the decoder reads in place. It cancels
// the token once, inside the chosen call, and then lets the call complete.
sealed class CancellingStream(
byte[] content,
int dataOffset,
int dataLength,
CancelAt cancelAt,
CancellationTokenSource cancellation)
: MemoryStream(content, writable: false)
{
private bool dataReadSeen;
public string? CancelledIn { get; private set; }
// Every Read overload of a type derived from MemoryStream arrives here.
public override int Read(byte[] buffer, int offset, int count)
{
long position = this.Position;
bool isDataRead = position == dataOffset && count == dataLength;
if (isDataRead && cancelAt == CancelAt.DataRead)
{
this.Cancel($"Read at {position}, {count} bytes (the chunk's data)");
}
else if (this.dataReadSeen && position == dataOffset + dataLength && cancelAt == CancelAt.CrcRead)
{
this.Cancel($"Read at {position}, {count} bytes (starts with the chunk's CRC)");
}
this.dataReadSeen |= isDataRead;
return base.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin loc)
{
if (this.dataReadSeen && cancelAt == CancelAt.SeekAfterDataRead
&& loc == SeekOrigin.Begin && offset == dataOffset + dataLength)
{
this.Cancel($"Seek to {offset} (the end of the chunk's data)");
}
return base.Seek(offset, loc);
}
private void Cancel(string where)
{
if (!cancellation.IsCancellationRequested)
{
this.CancelledIn = where;
cancellation.Cancel();
}
}
}
Recorded output
Windows, one fresh process per run. The Debug run on 4.1.3 differs from the
Release run only in its build= line.
ImageSharp 4.1.3, Release
imagesharp=4.1.3+751037d0c6384c97e77ee1abd86e2203ae8dbd95
runtime=.NET 10.0.12
os=Microsoft Windows 10.0.26300 X64
build=Release
input=generated PNG 256x256, 225226 bytes; first IDAT data at offset 62, 65535 bytes; decoder stream buffer 8096 bytes
case=A (during the chunk-data read) api=Image.IdentifyAsync
cancelled in : Read at 62, 65535 bytes (the chunk's data)
outcome : TaskCanceledException
observed at : BufferedReadStream.set_Position < BufferedReadStream.ReadToBufferDirectSlow < BufferedReadStream.Read < BufferedReadStreamExtensions.Read < PngDecoderCore.ReadChunkData < PngDecoderCore.TryReadChunk
undisposed count : +0
case=A (during the chunk-data read) api=Image.LoadAsync<Rgba32>
cancelled in : Read at 62, 65535 bytes (the chunk's data)
outcome : TaskCanceledException
observed at : BufferedReadStream.set_Position < BufferedReadStream.ReadToBufferDirectSlow < BufferedReadStream.Read < BufferedReadStreamExtensions.Read < PngDecoderCore.ReadChunkData < PngDecoderCore.TryReadChunk
undisposed count : +0
case=B (in the seek after the chunk-data read) api=Image.IdentifyAsync
cancelled in : Seek to 65597 (the end of the chunk's data)
outcome : TaskCanceledException
observed at : BufferedReadStream.Read < BufferedReadStreamExtensions.Read < PngDecoderCore.ReadChunkCrc < PngDecoderCore.ValidateChunk < PngDecoderCore.TryReadChunk < PngDecoderCore.Identify
undisposed count : +1
case=B (in the seek after the chunk-data read) api=Image.LoadAsync<Rgba32>
cancelled in : Seek to 65597 (the end of the chunk's data)
outcome : TaskCanceledException
observed at : BufferedReadStream.Read < BufferedReadStreamExtensions.Read < PngDecoderCore.ReadChunkCrc < PngDecoderCore.ValidateChunk < PngDecoderCore.TryReadChunk < PngDecoderCore.Decode
undisposed count : +1
case=C (during the CRC read) api=Image.IdentifyAsync
cancelled in : Read at 65597, 8096 bytes (starts with the chunk's CRC)
outcome : TaskCanceledException
observed at : BufferedReadStream.set_Position < PngDecoderCore.TryReadChunk < PngDecoderCore.Identify < ImageDecoderCore.Identify < PngDecoder.Identify < <>c__DisplayClass5_0.<IdentifyAsync>b__0
undisposed count : +1
case=C (during the CRC read) api=Image.LoadAsync<Rgba32>
cancelled in : Read at 65597, 8096 bytes (starts with the chunk's CRC)
outcome : TaskCanceledException
observed at : BufferedReadStream.set_Position < PngDecoderCore.TryReadChunk < PngDecoderCore.Decode < ImageDecoderCore.Decode < PngDecoder.Decode < SpecializedImageDecoder`1.Decode
undisposed count : +1
result=REPRODUCED: 4 buffer(s) left undisposed in cases B and C
ImageSharp 4.1.2, Release (for comparison: case A is the only difference)
imagesharp=4.1.2+dee414097e4be7bb35ef0209cf96bd0b61a7133a
runtime=.NET 10.0.12
os=Microsoft Windows 10.0.26300 X64
build=Release
input=generated PNG 256x256, 225226 bytes; first IDAT data at offset 62, 65535 bytes; decoder stream buffer 8096 bytes
case=A (during the chunk-data read) api=Image.IdentifyAsync
cancelled in : Read at 62, 65535 bytes (the chunk's data)
outcome : TaskCanceledException
observed at : BufferedReadStream.set_Position < BufferedReadStream.ReadToBufferDirectSlow < BufferedReadStream.Read < BufferedReadStreamExtensions.Read < PngDecoderCore.ReadChunkData < PngDecoderCore.TryReadChunk
undisposed count : +1
case=A (during the chunk-data read) api=Image.LoadAsync<Rgba32>
cancelled in : Read at 62, 65535 bytes (the chunk's data)
outcome : TaskCanceledException
observed at : BufferedReadStream.set_Position < BufferedReadStream.ReadToBufferDirectSlow < BufferedReadStream.Read < BufferedReadStreamExtensions.Read < PngDecoderCore.ReadChunkData < PngDecoderCore.TryReadChunk
undisposed count : +1
case=B (in the seek after the chunk-data read) api=Image.IdentifyAsync
cancelled in : Seek to 65597 (the end of the chunk's data)
outcome : TaskCanceledException
observed at : BufferedReadStream.Read < BufferedReadStreamExtensions.Read < PngDecoderCore.ReadChunkCrc < PngDecoderCore.ValidateChunk < PngDecoderCore.TryReadChunk < PngDecoderCore.Identify
undisposed count : +1
case=B (in the seek after the chunk-data read) api=Image.LoadAsync<Rgba32>
cancelled in : Seek to 65597 (the end of the chunk's data)
outcome : TaskCanceledException
observed at : BufferedReadStream.Read < BufferedReadStreamExtensions.Read < PngDecoderCore.ReadChunkCrc < PngDecoderCore.ValidateChunk < PngDecoderCore.TryReadChunk < PngDecoderCore.Decode
undisposed count : +1
case=C (during the CRC read) api=Image.IdentifyAsync
cancelled in : Read at 65597, 8096 bytes (starts with the chunk's CRC)
outcome : TaskCanceledException
observed at : BufferedReadStream.set_Position < PngDecoderCore.TryReadChunk < PngDecoderCore.Identify < ImageDecoderCore.Identify < PngDecoder.Identify < <>c__DisplayClass5_0.<IdentifyAsync>b__0
undisposed count : +1
case=C (during the CRC read) api=Image.LoadAsync<Rgba32>
cancelled in : Read at 65597, 8096 bytes (starts with the chunk's CRC)
outcome : TaskCanceledException
observed at : BufferedReadStream.set_Position < PngDecoderCore.TryReadChunk < PngDecoderCore.Decode < ImageDecoderCore.Decode < PngDecoder.Decode < SpecializedImageDecoder`1.Decode
undisposed count : +1
result=REPRODUCED: 4 buffer(s) left undisposed in cases B and C
- Lenguaje dominante
- C#
- Estrellas
- 8k
- Forks
- 904
- Merge medio
- 15 h 39 min
- PR fusionados (30 d)
- 12
Preparar el entorno
- Sin Dockerfile ni archivo de Docker Compose
- Tiene una plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de SixLabors/ImageSharp
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 45/100
SixLabors/ImageSharp#3218 · 3 comentarios ·
Los mantenedores suelen responder en 1 día
-
needs triage
Dificultad 4/5 3-5 días Aptitud para principiantes 25/100
SixLabors/ImageSharp#3216 ·
Los mantenedores suelen responder en 1 día
-
enhancement metadata:icc
Dificultad 4/5 3-5 días Aptitud para principiantes 48/100
SixLabors/ImageSharp#3198 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
Grayscale Icc profile colorAbiertobug metadata:icc
Dificultad 4/5 3-5 días Aptitud para principiantes 52/100
SixLabors/ImageSharp#3197 ·
Los mantenedores suelen responder en 1 día
-
formats:exr
Dificultad 5/5 Más de una semana Aptitud para principiantes 35/100
SixLabors/ImageSharp#3116 · 2 comentarios · 1 reacción ·
Los mantenedores suelen responder en 1 día
Todos los issues de SixLabors/ImageSharp
Issues similares
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 86/100
Devolutions/UniGetUI#5541 ·
Los mantenedores suelen responder en 1 día
-
[aw] PR Iteration Agent 🔧 failedAbiertoagentic-workflows Needs: Triage :mag:
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
Los mantenedores suelen responder en 1 día
-
[誤判定]Abiertolinux アプリ: ブラウザー 再現済み 要トリアージ 誤判定
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
yksr-melt/Meltype#853 · 2 comentarios ·
Los mantenedores suelen responder en 1 día
-
S: Untriaged
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
Trauma-Station/Trauma-Station#5073 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
Los mantenedores suelen responder en 1 día