Repository navigation
fix: Dispose chunk data buffer in ReadChunkData when read is cancelled #3203
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
JimBobSquarePants
merged 2 commits into
SixLabors:main
from
cloudsealed:fix/issue-3202-png-chunk-buffer-leak-on-cancel
Oct 9, 2026
+81
−1
Merged
Changes from 1 commit
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
84 changes: 84 additions & 0 deletions
84
tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.Cancellation.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| // Copyright (c) Six Labors. | ||
| // Licensed under the Six Labors Split License. | ||
|
|
||
| using SixLabors.ImageSharp.Formats; | ||
| using SixLabors.ImageSharp.Formats.Png; | ||
| using SixLabors.ImageSharp.PixelFormats; | ||
|
|
||
| namespace SixLabors.ImageSharp.Tests.Formats.Png; | ||
|
|
||
| public partial class PngDecoderTests | ||
| { | ||
| [Theory] | ||
| [InlineData(true)] | ||
| [InlineData(false)] | ||
| public void Decode_DisposesChunkDataBuffer_WhenChunkReadIsCancelled(bool identifyOnly) | ||
| { | ||
| // Every PNG starts with an 8-byte signature followed by the first chunk's 4-byte length | ||
| // and 4-byte type fields, so byte 16 is always the start of that chunk's data (IHDR here). | ||
| // Cancelling exactly there reproduces the leak from GH issue #3202: ReadChunkData rents | ||
| // its buffer from the MemoryAllocator and only then reads into it, so a cancellation | ||
| // observed by that read leaves the rented buffer undisposed without the try/finally. | ||
| // | ||
| // PngDecoder.Instance is used directly (rather than Image.Load/Identify) to bypass the | ||
| // generic format-detection probe, which reads the stream header independently and would | ||
| // otherwise observe the cancellation itself before PNG-specific parsing starts. | ||
| const int firstChunkDataOffset = 16; | ||
| byte[] pngBytes; | ||
| using (Image<Rgba32> source = new(4, 4)) | ||
| using (MemoryStream encoded = new()) | ||
| { | ||
| source.SaveAsPng(encoded); | ||
| pngBytes = encoded.ToArray(); | ||
| } | ||
|
|
||
| using CancelAtPositionStream stream = new(pngBytes, firstChunkDataOffset); | ||
|
|
||
| // BufferedReadStream prefetches up to this size into its own internal buffer, so a large | ||
| // size would let it read straight through the cancellation point in one call before | ||
| // PngDecoderCore.ReadChunkData ever allocates its buffer. Matching the buffer size to the | ||
| // cancellation point makes the length/type fields resolve from one prefetch and the | ||
| // chunk-data read (which exceeds the remaining buffer) go directly to the stream instead. | ||
| Configuration configuration = Configuration.CreateDefaultInstance(); | ||
| configuration.StreamProcessingBufferSize = firstChunkDataOffset - 8; | ||
| DecoderOptions options = new() { Configuration = configuration }; | ||
|
|
||
| if (identifyOnly) | ||
| { | ||
| Assert.ThrowsAny<OperationCanceledException>(() => PngDecoder.Instance.Identify(options, stream)); | ||
| } | ||
| else | ||
| { | ||
| Assert.ThrowsAny<OperationCanceledException>(() => | ||
| { | ||
| using Image<Rgba32> image = PngDecoder.Instance.Decode<Rgba32>(options, stream); | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// A stream over an in-memory PNG that throws <see cref="OperationCanceledException"/> once a | ||
| /// read is requested at or past <paramref name="cancelAtPosition"/>, regardless of the | ||
| /// requested length. Using the stream position (rather than the requested length) to decide | ||
| /// when to throw keeps the trigger point exact even though <see cref="BufferedReadStream"/> | ||
| /// prefetches more than the caller asks for. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Only the <c>byte[]</c> overload of <see cref="Read(byte[], int, int)"/> is overridden. | ||
| /// <see cref="MemoryStream"/> does not implement <see cref="Stream.Read(Span{byte})"/> in | ||
| /// terms of this overload (or vice versa), so overriding both and having each delegate to the | ||
| /// other would recurse indefinitely. | ||
| /// </remarks> | ||
| private sealed class CancelAtPositionStream(byte[] data, int cancelAtPosition) : MemoryStream(data) | ||
| { | ||
| public override int Read(byte[] buffer, int offset, int count) | ||
| { | ||
| if (this.Position >= cancelAtPosition) | ||
| { | ||
| throw new OperationCanceledException(); | ||
| } | ||
|
|
||
| return base.Read(buffer, offset, count); | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we use the existing
PausedMemoryStreamand cancel a real token here?The current helper overrides only
Read(byte[], int, int), but the chunk-data read reachesReadToBufferDirectSlow(Span<byte>). That bypasses the override, so the test does not trigger cancellation at the intended allocation.This replacement uses a four-byte buffer and cancels while the IHDR type is being read. That read completes, then
ReadChunkDataallocates its buffer and the following read observes the cancelled token. The existing[ValidateDisposedMemoryAllocations]onPngDecoderTestschecks that the allocation is disposed.I verified this replacement in Release on .NET 10. Both cases fail without the disposal fix, each detecting one undisposed buffer, and both pass with the fix.