diff --git a/src/Directory.Build.props b/src/Directory.Build.props index d211992a94..faa29865f2 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -27,6 +27,7 @@ + diff --git a/src/ImageSharp/Diagnostics/MemoryDiagnostics.cs b/src/ImageSharp/Diagnostics/MemoryDiagnostics.cs index d83e737f12..89f18cff61 100644 --- a/src/ImageSharp/Diagnostics/MemoryDiagnostics.cs +++ b/src/ImageSharp/Diagnostics/MemoryDiagnostics.cs @@ -1,6 +1,7 @@ // Copyright (c) Six Labors. // Licensed under the Apache License, Version 2.0. +using System; using System.Threading; namespace SixLabors.ImageSharp.Diagnostics @@ -47,6 +48,16 @@ public static event UndisposedAllocationDelegate UndisposedAllocation } } + /// + /// Fires when ImageSharp allocates memory from a MemoryAllocator + /// + internal static event Action MemoryAllocated; + + /// + /// Fires when ImageSharp releases memory allocated from a MemoryAllocator + /// + internal static event Action MemoryReleased; + /// /// Gets a value indicating the total number of memory resource objects leaked to the finalizer. /// @@ -54,11 +65,17 @@ public static event UndisposedAllocationDelegate UndisposedAllocation internal static bool UndisposedAllocationSubscribed => Volatile.Read(ref undisposedAllocationSubscriptionCounter) > 0; - internal static void IncrementTotalUndisposedAllocationCount() => + internal static void IncrementTotalUndisposedAllocationCount() + { Interlocked.Increment(ref totalUndisposedAllocationCount); + MemoryAllocated?.Invoke(); + } - internal static void DecrementTotalUndisposedAllocationCount() => + internal static void DecrementTotalUndisposedAllocationCount() + { Interlocked.Decrement(ref totalUndisposedAllocationCount); + MemoryReleased?.Invoke(); + } internal static void RaiseUndisposedMemoryResource(string allocationStackTrace) { diff --git a/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs b/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs index 962c9f0d67..ee0a312803 100644 --- a/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs +++ b/src/ImageSharp/Formats/Bmp/BmpDecoderCore.cs @@ -122,11 +122,12 @@ public BmpDecoderCore(Configuration configuration, IBmpDecoderOptions options) public Image Decode(BufferedReadStream stream, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { + Image image = null; try { int bytesPerColorMapEntry = this.ReadImageHeaders(stream, out bool inverted, out byte[] palette); - var image = new Image(this.Configuration, this.infoHeader.Width, this.infoHeader.Height, this.metadata); + image = new Image(this.Configuration, this.infoHeader.Width, this.infoHeader.Height, this.metadata); Buffer2D pixels = image.GetRootFramePixelBuffer(); @@ -193,8 +194,14 @@ public Image Decode(BufferedReadStream stream, CancellationToken } catch (IndexOutOfRangeException e) { + image?.Dispose(); throw new ImageFormatException("Bitmap does not have a valid format.", e); } + catch + { + image?.Dispose(); + throw; + } } /// diff --git a/src/ImageSharp/Formats/Jpeg/Components/Decoder/SpectralConverter{TPixel}.cs b/src/ImageSharp/Formats/Jpeg/Components/Decoder/SpectralConverter{TPixel}.cs index 430adeb21d..532892e060 100644 --- a/src/ImageSharp/Formats/Jpeg/Components/Decoder/SpectralConverter{TPixel}.cs +++ b/src/ImageSharp/Formats/Jpeg/Components/Decoder/SpectralConverter{TPixel}.cs @@ -95,7 +95,9 @@ public Buffer2D GetPixelBuffer(CancellationToken cancellationToken) } } - return this.pixelBuffer; + var buffer = this.pixelBuffer; + this.pixelBuffer = null; + return buffer; } /// @@ -210,6 +212,7 @@ public void Dispose() this.rgbBuffer?.Dispose(); this.paddedProxyPixelRow?.Dispose(); + this.pixelBuffer?.Dispose(); } } } diff --git a/src/ImageSharp/Formats/Png/PngDecoderCore.cs b/src/ImageSharp/Formats/Png/PngDecoderCore.cs index 497dc39674..12770bc521 100644 --- a/src/ImageSharp/Formats/Png/PngDecoderCore.cs +++ b/src/ImageSharp/Formats/Png/PngDecoderCore.cs @@ -227,10 +227,16 @@ public Image Decode(BufferedReadStream stream, CancellationToken return image; } + catch + { + image?.Dispose(); + throw; + } finally { this.scanline?.Dispose(); this.previousScanline?.Dispose(); + this.nextChunk?.Data?.Dispose(); } } @@ -472,6 +478,8 @@ private void InitializeImage(ImageMetadata metadata, out Image i this.bytesPerSample = this.header.BitDepth / 8; } + this.previousScanline?.Dispose(); + this.scanline?.Dispose(); this.previousScanline = this.memoryAllocator.Allocate(this.bytesPerScanline, AllocationOptions.Clean); this.scanline = this.Configuration.MemoryAllocator.Allocate(this.bytesPerScanline, AllocationOptions.Clean); } @@ -1359,6 +1367,7 @@ private int ReadNextDataChunk() { if (chunk.Type == PngChunkType.Data) { + chunk.Data?.Dispose(); return chunk.Length; } @@ -1453,6 +1462,9 @@ private void ValidateChunk(in PngChunk chunk) if (validCrc != inputCrc) { string chunkTypeName = Encoding.ASCII.GetString(chunkType); + + // ensure when throwing we dispose the data back to the memory allocator + chunk.Data?.Dispose(); PngThrowHelper.ThrowInvalidChunkCrc(chunkTypeName); } } diff --git a/src/ImageSharp/Formats/Tiff/Compression/Decompressors/JpegTiffCompression.cs b/src/ImageSharp/Formats/Tiff/Compression/Decompressors/JpegTiffCompression.cs index cfbc32f4f6..4e788c76af 100644 --- a/src/ImageSharp/Formats/Tiff/Compression/Decompressors/JpegTiffCompression.cs +++ b/src/ImageSharp/Formats/Tiff/Compression/Decompressors/JpegTiffCompression.cs @@ -65,7 +65,8 @@ protected override void Decompress(BufferedReadStream stream, int byteCount, int jpegDecoder.ParseStream(stream, scanDecoderGray, CancellationToken.None); // TODO: Should we pass through the CancellationToken from the tiff decoder? - CopyImageBytesToBuffer(buffer, spectralConverterGray.GetPixelBuffer(CancellationToken.None)); + using var decompressedBuffer = spectralConverterGray.GetPixelBuffer(CancellationToken.None); + CopyImageBytesToBuffer(buffer, decompressedBuffer); break; } @@ -81,7 +82,8 @@ protected override void Decompress(BufferedReadStream stream, int byteCount, int jpegDecoder.ParseStream(stream, scanDecoder, CancellationToken.None); // TODO: Should we pass through the CancellationToken from the tiff decoder? - CopyImageBytesToBuffer(buffer, spectralConverter.GetPixelBuffer(CancellationToken.None)); + using var decompressedBuffer = spectralConverter.GetPixelBuffer(CancellationToken.None); + CopyImageBytesToBuffer(buffer, decompressedBuffer); break; } diff --git a/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs b/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs index 1198c519a2..1cd3d2c0c1 100644 --- a/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs +++ b/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs @@ -157,40 +157,52 @@ public TiffDecoderCore(Configuration configuration, ITiffDecoderOptions options) public Image Decode(BufferedReadStream stream, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { - this.inputStream = stream; - var reader = new DirectoryReader(stream, this.Configuration.MemoryAllocator); - - IEnumerable directories = reader.Read(); - this.byteOrder = reader.ByteOrder; - this.isBigTiff = reader.IsBigTiff; - var frames = new List>(); - foreach (ExifProfile ifd in directories) + try { - cancellationToken.ThrowIfCancellationRequested(); - ImageFrame frame = this.DecodeFrame(ifd, cancellationToken); - frames.Add(frame); + this.inputStream = stream; + var reader = new DirectoryReader(stream, this.Configuration.MemoryAllocator); + + IEnumerable directories = reader.Read(); + this.byteOrder = reader.ByteOrder; + this.isBigTiff = reader.IsBigTiff; - if (this.decodingMode is FrameDecodingMode.First) + foreach (ExifProfile ifd in directories) { - break; + cancellationToken.ThrowIfCancellationRequested(); + ImageFrame frame = this.DecodeFrame(ifd, cancellationToken); + frames.Add(frame); + + if (this.decodingMode is FrameDecodingMode.First) + { + break; + } } - } - ImageMetadata metadata = TiffDecoderMetadataCreator.Create(frames, this.ignoreMetadata, reader.ByteOrder, reader.IsBigTiff); + ImageMetadata metadata = TiffDecoderMetadataCreator.Create(frames, this.ignoreMetadata, reader.ByteOrder, reader.IsBigTiff); - // TODO: Tiff frames can have different sizes. - ImageFrame root = frames[0]; - this.Dimensions = root.Size(); - foreach (ImageFrame frame in frames) - { - if (frame.Size() != root.Size()) + // TODO: Tiff frames can have different sizes. + ImageFrame root = frames[0]; + this.Dimensions = root.Size(); + foreach (ImageFrame frame in frames) { - TiffThrowHelper.ThrowNotSupported("Images with different sizes are not supported"); + if (frame.Size() != root.Size()) + { + TiffThrowHelper.ThrowNotSupported("Images with different sizes are not supported"); + } } + + return new Image(this.Configuration, metadata, frames); } + catch + { + foreach (ImageFrame f in frames) + { + f.Dispose(); + } - return new Image(this.Configuration, metadata, frames); + throw; + } } /// @@ -240,8 +252,8 @@ private ImageFrame DecodeFrame(ExifProfile tags, CancellationTok var stripOffsetsArray = (Array)tags.GetValueInternal(ExifTag.StripOffsets).GetValue(); var stripByteCountsArray = (Array)tags.GetValueInternal(ExifTag.StripByteCounts).GetValue(); - IMemoryOwner stripOffsetsMemory = this.ConvertNumbers(stripOffsetsArray, out Span stripOffsets); - IMemoryOwner stripByteCountsMemory = this.ConvertNumbers(stripByteCountsArray, out Span stripByteCounts); + using IMemoryOwner stripOffsetsMemory = this.ConvertNumbers(stripOffsetsArray, out Span stripOffsets); + using IMemoryOwner stripByteCountsMemory = this.ConvertNumbers(stripByteCountsArray, out Span stripByteCounts); if (this.PlanarConfiguration == TiffPlanarConfiguration.Planar) { @@ -262,8 +274,6 @@ private ImageFrame DecodeFrame(ExifProfile tags, CancellationTok cancellationToken); } - stripOffsetsMemory?.Dispose(); - stripByteCountsMemory?.Dispose(); return frame; } diff --git a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs index 9d18e5d821..7052be4ea6 100644 --- a/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs +++ b/src/ImageSharp/Formats/Webp/WebpDecoderCore.cs @@ -82,38 +82,47 @@ public WebpDecoderCore(Configuration configuration, IWebpDecoderOptions options) public Image Decode(BufferedReadStream stream, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { - this.Metadata = new ImageMetadata(); - this.currentStream = stream; + Image image = null; + try + { + this.Metadata = new ImageMetadata(); + this.currentStream = stream; - uint fileSize = this.ReadImageHeader(); + uint fileSize = this.ReadImageHeader(); - using (this.webImageInfo = this.ReadVp8Info()) - { - if (this.webImageInfo.Features is { Animation: true }) + using (this.webImageInfo = this.ReadVp8Info()) { - WebpThrowHelper.ThrowNotSupportedException("Animations are not supported"); - } + if (this.webImageInfo.Features is { Animation: true }) + { + WebpThrowHelper.ThrowNotSupportedException("Animations are not supported"); + } - var image = new Image(this.Configuration, (int)this.webImageInfo.Width, (int)this.webImageInfo.Height, this.Metadata); - Buffer2D pixels = image.GetRootFramePixelBuffer(); - if (this.webImageInfo.IsLossless) - { - var losslessDecoder = new WebpLosslessDecoder(this.webImageInfo.Vp8LBitReader, this.memoryAllocator, this.Configuration); - losslessDecoder.Decode(pixels, image.Width, image.Height); - } - else - { - var lossyDecoder = new WebpLossyDecoder(this.webImageInfo.Vp8BitReader, this.memoryAllocator, this.Configuration); - lossyDecoder.Decode(pixels, image.Width, image.Height, this.webImageInfo); - } + image = new Image(this.Configuration, (int)this.webImageInfo.Width, (int)this.webImageInfo.Height, this.Metadata); + Buffer2D pixels = image.GetRootFramePixelBuffer(); + if (this.webImageInfo.IsLossless) + { + var losslessDecoder = new WebpLosslessDecoder(this.webImageInfo.Vp8LBitReader, this.memoryAllocator, this.Configuration); + losslessDecoder.Decode(pixels, image.Width, image.Height); + } + else + { + var lossyDecoder = new WebpLossyDecoder(this.webImageInfo.Vp8BitReader, this.memoryAllocator, this.Configuration); + lossyDecoder.Decode(pixels, image.Width, image.Height, this.webImageInfo); + } - // There can be optional chunks after the image data, like EXIF and XMP. - if (this.webImageInfo.Features != null) - { - this.ParseOptionalChunks(this.webImageInfo.Features); - } + // There can be optional chunks after the image data, like EXIF and XMP. + if (this.webImageInfo.Features != null) + { + this.ParseOptionalChunks(this.webImageInfo.Features); + } - return image; + return image; + } + } + catch + { + image?.Dispose(); + throw; } } diff --git a/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs index 71d0ab34a4..43ec45a34f 100644 --- a/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Bmp/BmpDecoderTests.cs @@ -20,6 +20,7 @@ namespace SixLabors.ImageSharp.Tests.Formats.Bmp { [Trait("Format", "Bmp")] + [ValidateDisposedMemoryAllocations] public class BmpDecoderTests { public const PixelTypes CommonNonDefaultPixelTypes = PixelTypes.Rgba32 | PixelTypes.Bgra32 | PixelTypes.RgbaVector; diff --git a/tests/ImageSharp.Tests/Formats/Gif/GifDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Gif/GifDecoderTests.cs index 1922b161f2..7a5241c5a8 100644 --- a/tests/ImageSharp.Tests/Formats/Gif/GifDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Gif/GifDecoderTests.cs @@ -18,6 +18,7 @@ namespace SixLabors.ImageSharp.Tests.Formats.Gif { [Trait("Format", "Gif")] + [ValidateDisposedMemoryAllocations] public class GifDecoderTests { private const PixelTypes TestPixelTypes = PixelTypes.Rgba32 | PixelTypes.RgbaVector | PixelTypes.Argb32; diff --git a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.Metadata.cs b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.Metadata.cs index 9864d62b8f..d9915f17d6 100644 --- a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.Metadata.cs +++ b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.Metadata.cs @@ -179,11 +179,16 @@ private static void TestImageInfo(string imagePath, IImageDecoder decoder, bool var testFile = TestFile.Create(imagePath); using (var stream = new MemoryStream(testFile.Bytes, false)) { - IImageInfo imageInfo = useIdentify - ? ((IImageInfoDetector)decoder).Identify(Configuration.Default, stream, default) - : decoder.Decode(Configuration.Default, stream, default); - - test(imageInfo); + if (useIdentify) + { + IImageInfo imageInfo = ((IImageInfoDetector)decoder).Identify(Configuration.Default, stream, default); + test(imageInfo); + } + else + { + using var img = decoder.Decode(Configuration.Default, stream, default); + test(img); + } } } diff --git a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs index faf40ccf7d..1faa6f0f4c 100644 --- a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs @@ -22,6 +22,7 @@ namespace SixLabors.ImageSharp.Tests.Formats.Jpg { // TODO: Scatter test cases into multiple test classes [Trait("Format", "Jpg")] + [ValidateDisposedMemoryAllocations] public partial class JpegDecoderTests { public const PixelTypes CommonNonDefaultPixelTypes = PixelTypes.Rgba32 | PixelTypes.Argb32 | PixelTypes.Bgr24 | PixelTypes.RgbaVector; diff --git a/tests/ImageSharp.Tests/Formats/Pbm/PbmDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Pbm/PbmDecoderTests.cs index 97237bca59..eb3bc8c9a5 100644 --- a/tests/ImageSharp.Tests/Formats/Pbm/PbmDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Pbm/PbmDecoderTests.cs @@ -11,6 +11,7 @@ namespace SixLabors.ImageSharp.Tests.Formats.Pbm { [Trait("Format", "Pbm")] + [ValidateDisposedMemoryAllocations] public class PbmDecoderTests { [Theory] diff --git a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs index 752036126f..a4fcf63baf 100644 --- a/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Png/PngDecoderTests.cs @@ -19,6 +19,7 @@ namespace SixLabors.ImageSharp.Tests.Formats.Png { [Trait("Format", "Png")] + [ValidateDisposedMemoryAllocations] public partial class PngDecoderTests { private const PixelTypes TestPixelTypes = PixelTypes.Rgba32 | PixelTypes.RgbaVector | PixelTypes.Argb32; diff --git a/tests/ImageSharp.Tests/Formats/Tga/TgaDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Tga/TgaDecoderTests.cs index 55dc2ecdd8..e83c5a98cc 100644 --- a/tests/ImageSharp.Tests/Formats/Tga/TgaDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tga/TgaDecoderTests.cs @@ -16,6 +16,7 @@ namespace SixLabors.ImageSharp.Tests.Formats.Tga { [Trait("Format", "Tga")] + [ValidateDisposedMemoryAllocations] public class TgaDecoderTests { private static TgaDecoder TgaDecoder => new(); diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs index 39fa50c93c..9460f3a351 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderTests.cs @@ -14,6 +14,7 @@ namespace SixLabors.ImageSharp.Tests.Formats.Tiff { [Trait("Format", "Tiff")] + [ValidateDisposedMemoryAllocations] public class TiffDecoderTests : TiffDecoderBaseTester { public static readonly string[] MultiframeTestImages = Multiframes; diff --git a/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs b/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs index 1c92fdf335..f29fa5d793 100644 --- a/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/WebP/WebpDecoderTests.cs @@ -13,6 +13,7 @@ namespace SixLabors.ImageSharp.Tests.Formats.Webp { [Trait("Format", "Webp")] + [ValidateDisposedMemoryAllocations] public class WebpDecoderTests { private static WebpDecoder WebpDecoder => new(); diff --git a/tests/ImageSharp.Tests/Image/ImageTests.LoadPixelData.cs b/tests/ImageSharp.Tests/Image/ImageTests.LoadPixelData.cs index a4044b906c..7683ee6889 100644 --- a/tests/ImageSharp.Tests/Image/ImageTests.LoadPixelData.cs +++ b/tests/ImageSharp.Tests/Image/ImageTests.LoadPixelData.cs @@ -14,6 +14,7 @@ public class LoadPixelData [Theory] [InlineData(false)] [InlineData(true)] + [ValidateDisposedMemoryAllocations] public void FromPixels(bool useSpan) { Rgba32[] data = { Color.Black, Color.White, Color.White, Color.Black, }; diff --git a/tests/ImageSharp.Tests/Image/LargeImageIntegrationTests.cs b/tests/ImageSharp.Tests/Image/LargeImageIntegrationTests.cs index 357d02be4b..ce1f902e59 100644 --- a/tests/ImageSharp.Tests/Image/LargeImageIntegrationTests.cs +++ b/tests/ImageSharp.Tests/Image/LargeImageIntegrationTests.cs @@ -55,6 +55,8 @@ public void PreferContiguousImageBuffers_LoadImage_BufferIsContiguous(string for static void RunTest(string formatInner) { + using IDisposable mem = MemoryAllocatorValidator.MonitorAllocations(); + Configuration configuration = Configuration.Default.Clone(); configuration.PreferContiguousImageBuffers = true; IImageEncoder encoder = configuration.ImageFormatsManager.FindEncoder( diff --git a/tests/ImageSharp.Tests/MemoryAllocatorValidator.cs b/tests/ImageSharp.Tests/MemoryAllocatorValidator.cs new file mode 100644 index 0000000000..13664ee9b2 --- /dev/null +++ b/tests/ImageSharp.Tests/MemoryAllocatorValidator.cs @@ -0,0 +1,77 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using System; +using System.Threading; +using SixLabors.ImageSharp.Diagnostics; +using Xunit; + +namespace SixLabors.ImageSharp.Tests +{ + public static class MemoryAllocatorValidator + { + private static readonly AsyncLocal LocalInstance = new(); + + public static bool MonitoringAllocations => LocalInstance.Value != null; + + static MemoryAllocatorValidator() + { + MemoryDiagnostics.MemoryAllocated += MemoryDiagnostics_MemoryAllocated; + MemoryDiagnostics.MemoryReleased += MemoryDiagnostics_MemoryReleased; + } + + private static void MemoryDiagnostics_MemoryReleased() + { + TestMemoryDiagnostics backing = LocalInstance.Value; + if (backing != null) + { + backing.TotalRemainingAllocated--; + } + } + + private static void MemoryDiagnostics_MemoryAllocated() + { + TestMemoryDiagnostics backing = LocalInstance.Value; + if (backing != null) + { + backing.TotalAllocated++; + backing.TotalRemainingAllocated++; + } + } + + public static TestMemoryDiagnostics MonitorAllocations() + { + var diag = new TestMemoryDiagnostics(); + LocalInstance.Value = diag; + return diag; + } + + public static void StopMonitoringAllocations() => LocalInstance.Value = null; + + public static void ValidateAllocations(int expectedAllocationCount = 0) + => LocalInstance.Value?.Validate(expectedAllocationCount); + + public class TestMemoryDiagnostics : IDisposable + { + public int TotalAllocated { get; set; } + + public int TotalRemainingAllocated { get; set; } + + public void Validate(int expectedAllocationCount) + { + var count = this.TotalRemainingAllocated; + var pass = expectedAllocationCount == count; + Assert.True(pass, $"Expected a {expectedAllocationCount} undisposed buffers but found {count}"); + } + + public void Dispose() + { + this.Validate(0); + if (LocalInstance.Value == this) + { + StopMonitoringAllocations(); + } + } + } + } +} diff --git a/tests/ImageSharp.Tests/TestUtilities/ImageComparison/ImageComparingUtils.cs b/tests/ImageSharp.Tests/TestUtilities/ImageComparison/ImageComparingUtils.cs index 1801d6b590..fa25846748 100644 --- a/tests/ImageSharp.Tests/TestUtilities/ImageComparison/ImageComparingUtils.cs +++ b/tests/ImageSharp.Tests/TestUtilities/ImageComparison/ImageComparingUtils.cs @@ -26,7 +26,7 @@ public static void CompareWithReferenceDecoder( } var testFile = TestFile.Create(path); - Image magickImage = DecodeWithMagick(new FileInfo(testFile.FullPath)); + using Image magickImage = DecodeWithMagick(new FileInfo(testFile.FullPath)); if (useExactComparer) { ImageComparer.Exact.VerifySimilarity(magickImage, image); diff --git a/tests/ImageSharp.Tests/TestUtilities/ImageProviders/FileProvider.cs b/tests/ImageSharp.Tests/TestUtilities/ImageProviders/FileProvider.cs index e2e7d73bc6..63c5ce31a2 100644 --- a/tests/ImageSharp.Tests/TestUtilities/ImageProviders/FileProvider.cs +++ b/tests/ImageSharp.Tests/TestUtilities/ImageProviders/FileProvider.cs @@ -7,6 +7,7 @@ using System.IO; using System.Reflection; using System.Threading.Tasks; +using SixLabors.ImageSharp.Diagnostics; using SixLabors.ImageSharp.Formats; using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.PixelFormats; @@ -158,8 +159,13 @@ public override Image GetImage(IImageDecoder decoder) return this.LoadImage(decoder); } - var key = new Key(this.PixelType, this.FilePath, decoder); + // do not cache so we can track allocation correctly when validating memory + if (MemoryAllocatorValidator.MonitoringAllocations) + { + return this.LoadImage(decoder); + } + var key = new Key(this.PixelType, this.FilePath, decoder); Image cachedImage = Cache.GetOrAdd(key, _ => this.LoadImage(decoder)); return cachedImage.Clone(this.Configuration); diff --git a/tests/ImageSharp.Tests/ValidateDisposedMemoryAllocationsAttribute.cs b/tests/ImageSharp.Tests/ValidateDisposedMemoryAllocationsAttribute.cs new file mode 100644 index 0000000000..65ed990dd7 --- /dev/null +++ b/tests/ImageSharp.Tests/ValidateDisposedMemoryAllocationsAttribute.cs @@ -0,0 +1,33 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using System; +using System.Diagnostics; +using System.Reflection; +using Xunit.Sdk; + +namespace SixLabors.ImageSharp.Tests +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)] + public class ValidateDisposedMemoryAllocationsAttribute : BeforeAfterTestAttribute + { + private readonly int expected = 0; + + public ValidateDisposedMemoryAllocationsAttribute() + : this(0) + { + } + + public ValidateDisposedMemoryAllocationsAttribute(int expected) + => this.expected = expected; + + public override void Before(MethodInfo methodUnderTest) + => MemoryAllocatorValidator.MonitorAllocations(); + + public override void After(MethodInfo methodUnderTest) + { + MemoryAllocatorValidator.ValidateAllocations(this.expected); + MemoryAllocatorValidator.StopMonitoringAllocations(); + } + } +}