diff --git a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs index 27d70fd18..67df6a881 100644 --- a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs @@ -12,6 +12,7 @@ using SixLabors.ImageSharp.IO; using SixLabors.ImageSharp.Memory; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Tests.Formats.Jpg.Utils; +using SixLabors.ImageSharp.Tests.TestUtilities; using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; using Xunit; @@ -127,60 +128,53 @@ namespace SixLabors.ImageSharp.Tests.Formats.Jpg } [Theory] - [InlineData(TestImages.Jpeg.Baseline.Jpeg420Small, 0)] - [InlineData(TestImages.Jpeg.Issues.ExifGetString750Transform, 1)] - [InlineData(TestImages.Jpeg.Issues.ExifGetString750Transform, 15)] - [InlineData(TestImages.Jpeg.Issues.ExifGetString750Transform, 30)] - [InlineData(TestImages.Jpeg.Issues.BadRstProgressive518, 1)] - [InlineData(TestImages.Jpeg.Issues.BadRstProgressive518, 15)] - [InlineData(TestImages.Jpeg.Issues.BadRstProgressive518, 30)] - public async Task Decode_IsCancellable(string fileName, int cancellationDelayMs) + [InlineData(0)] + [InlineData(0.5)] + [InlineData(0.9)] + public async Task Decode_IsCancellable(int percentageOfStreamReadToCancel) { - // Decoding these huge files took 300ms on i7-8650U in 2020. 30ms should be safe for cancellation delay. - string hugeFile = Path.Combine( - TestEnvironment.InputImagesDirectoryFullPath, - fileName); - - const int numberOfRuns = 5; - - for (int i = 0; i < numberOfRuns; i++) + var cts = new CancellationTokenSource(); + var file = Path.Combine(TestEnvironment.InputImagesDirectoryFullPath, TestImages.Jpeg.Baseline.Jpeg420Small); + using var pausedStream = new PausedStream(file); + pausedStream.OnWaiting(s => { - var cts = new CancellationTokenSource(); - if (cancellationDelayMs == 0) + if (s.Position >= s.Length * percentageOfStreamReadToCancel) { cts.Cancel(); + pausedStream.Release(); } else { - cts.CancelAfter(cancellationDelayMs); - } - - try - { - using Image image = await Image.LoadAsync(hugeFile, cts.Token); - } - catch (TaskCanceledException) - { - // Successfully observed a cancellation - return; + // allows this/next wait to unblock + pausedStream.Next(); } - } + }); - throw new Exception($"No cancellation happened out of {numberOfRuns} runs!"); + var config = Configuration.CreateDefaultInstance(); + config.FileSystem = new SingleStreamFileSystem(pausedStream); + await Assert.ThrowsAsync(async () => + { + using Image image = await Image.LoadAsync(config, "someFakeFile", cts.Token); + }); } - [Theory(Skip = "Identify is too fast, doesn't work reliably.")] - [InlineData(TestImages.Jpeg.Baseline.Exif)] - [InlineData(TestImages.Jpeg.Progressive.Bad.ExifUndefType)] - public async Task Identify_IsCancellable(string fileName) + [Fact] + public async Task Identify_IsCancellable() { - string file = Path.Combine( - TestEnvironment.InputImagesDirectoryFullPath, - fileName); - var cts = new CancellationTokenSource(); - cts.CancelAfter(TimeSpan.FromTicks(1)); - await Assert.ThrowsAsync(() => Image.IdentifyAsync(file, cts.Token)); + + var file = Path.Combine(TestEnvironment.InputImagesDirectoryFullPath, TestImages.Jpeg.Baseline.Jpeg420Small); + using var pausedStream = new PausedStream(file); + pausedStream.OnWaiting(s => + { + cts.Cancel(); + pausedStream.Release(); + }); + + var config = Configuration.CreateDefaultInstance(); + config.FileSystem = new SingleStreamFileSystem(pausedStream); + + await Assert.ThrowsAsync(async () => await Image.IdentifyAsync(config, "someFakeFile", cts.Token)); } // DEBUG ONLY! diff --git a/tests/ImageSharp.Tests/Formats/Jpg/JpegEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Jpg/JpegEncoderTests.cs index a03ceefaf..3c48865c7 100644 --- a/tests/ImageSharp.Tests/Formats/Jpg/JpegEncoderTests.cs +++ b/tests/ImageSharp.Tests/Formats/Jpg/JpegEncoderTests.cs @@ -13,6 +13,7 @@ using SixLabors.ImageSharp.Metadata.Profiles.Icc; using SixLabors.ImageSharp.Metadata.Profiles.Iptc; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Tests.TestUtilities; using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; using Xunit; @@ -309,29 +310,34 @@ namespace SixLabors.ImageSharp.Tests.Formats.Jpg Assert.Equal(values.Entries, actual.Entries); } - [Theory(Skip = "TODO: Too Flaky")] - [InlineData(JpegSubsample.Ratio420, 0)] - [InlineData(JpegSubsample.Ratio420, 3)] - [InlineData(JpegSubsample.Ratio420, 10)] - [InlineData(JpegSubsample.Ratio444, 0)] - [InlineData(JpegSubsample.Ratio444, 3)] - [InlineData(JpegSubsample.Ratio444, 10)] - public async Task Encode_IsCancellable(JpegSubsample subsample, int cancellationDelayMs) + [Theory] + [InlineData(JpegSubsample.Ratio420)] + [InlineData(JpegSubsample.Ratio444)] + public async Task Encode_IsCancellable(JpegSubsample subsample) { - using var image = new Image(5000, 5000); - using var stream = new MemoryStream(); var cts = new CancellationTokenSource(); - if (cancellationDelayMs == 0) - { - cts.Cancel(); - } - else + using var pausedStream = new PausedStream(new MemoryStream()); + pausedStream.OnWaiting(s => { - cts.CancelAfter(cancellationDelayMs); - } + // after some writing + if (s.Position >= 500) + { + cts.Cancel(); + pausedStream.Release(); + } + else + { + // allows this/next wait to unblock + pausedStream.Next(); + } + }); - var encoder = new JpegEncoder() { Subsample = subsample }; - await Assert.ThrowsAsync(() => image.SaveAsync(stream, encoder, cts.Token)); + using var image = new Image(5000, 5000); + await Assert.ThrowsAsync(async () => + { + var encoder = new JpegEncoder() { Subsample = subsample }; + await image.SaveAsync(pausedStream, encoder, cts.Token); + }); } } } diff --git a/tests/ImageSharp.Tests/Image/ImageTests.SaveAsync.cs b/tests/ImageSharp.Tests/Image/ImageTests.SaveAsync.cs index 825bd55e4..8bb121349 100644 --- a/tests/ImageSharp.Tests/Image/ImageTests.SaveAsync.cs +++ b/tests/ImageSharp.Tests/Image/ImageTests.SaveAsync.cs @@ -140,10 +140,15 @@ namespace SixLabors.ImageSharp.Tests using var stream = new MemoryStream(); var asyncStream = new AsyncStreamWrapper(stream, () => false); var cts = new CancellationTokenSource(); - cts.CancelAfter(TimeSpan.FromTicks(1)); - await Assert.ThrowsAnyAsync(() => - image.SaveAsync(asyncStream, encoder, cts.Token)); + var pausedStream = new PausedStream(asyncStream); + pausedStream.OnWaiting(s => + { + cts.Cancel(); + pausedStream.Release(); + }); + + await Assert.ThrowsAsync(async () => await image.SaveAsync(pausedStream, encoder, cts.Token)); } } } diff --git a/tests/ImageSharp.Tests/TestUtilities/PausedStream.cs b/tests/ImageSharp.Tests/TestUtilities/PausedStream.cs new file mode 100644 index 000000000..4d3646301 --- /dev/null +++ b/tests/ImageSharp.Tests/TestUtilities/PausedStream.cs @@ -0,0 +1,145 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace SixLabors.ImageSharp.Tests.TestUtilities +{ + public class PausedStream : Stream + { + private readonly SemaphoreSlim semaphore = new SemaphoreSlim(0); + + private readonly CancellationTokenSource cancelationTokenSource = new CancellationTokenSource(); + + private readonly Stream innerStream; + private Action onWaitingCallback; + + public void OnWaiting(Action onWaitingCallback) => this.onWaitingCallback = onWaitingCallback; + + public void OnWaiting(Action onWaitingCallback) => this.OnWaiting(_ => onWaitingCallback()); + + public void Release() + { + this.semaphore.Release(); + this.cancelationTokenSource.Cancel(); + } + + public void Next() => this.semaphore.Release(); + + private void Wait() + { + if (this.cancelationTokenSource.IsCancellationRequested) + { + return; + } + + this.onWaitingCallback?.Invoke(this.innerStream); + + try + { + this.semaphore.Wait(this.cancelationTokenSource.Token); + } + catch (OperationCanceledException) + { + // ignore this as its just used to unlock any waits in progress + } + } + + private async Task Await(Func action) + { + await Task.Yield(); + this.Wait(); + await action(); + } + + private async Task Await(Func> action) + { + await Task.Yield(); + this.Wait(); + return await action(); + } + + private T Await(Func action) + { + this.Wait(); + return action(); + } + + private void Await(Action action) + { + this.Wait(); + action(); + } + + public PausedStream(byte[] data) + : this(new MemoryStream(data)) + { + } + + public PausedStream(string filePath) + : this(File.OpenRead(filePath)) + { + } + + public PausedStream(Stream innerStream) => this.innerStream = innerStream; + + public override bool CanTimeout => this.innerStream.CanTimeout; + + public override void Close() => this.Await(() => this.innerStream.Close()); + + public override Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) => this.Await(() => this.innerStream.CopyToAsync(destination, bufferSize, cancellationToken)); + + public override bool CanRead => this.innerStream.CanRead; + + public override bool CanSeek => this.innerStream.CanSeek; + + public override bool CanWrite => this.innerStream.CanWrite; + + public override long Length => this.Await(() => this.innerStream.Length); + + public override long Position { get => this.Await(() => this.innerStream.Position); set => this.Await(() => this.innerStream.Position = value); } + + public override void Flush() => this.Await(() => this.innerStream.Flush()); + + public override int Read(byte[] buffer, int offset, int count) => this.Await(() => this.innerStream.Read(buffer, offset, count)); + + public override long Seek(long offset, SeekOrigin origin) => this.Await(() => this.innerStream.Seek(offset, origin)); + + public override void SetLength(long value) => this.Await(() => this.innerStream.SetLength(value)); + + public override void Write(byte[] buffer, int offset, int count) => this.Await(() => this.innerStream.Write(buffer, offset, count)); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => this.Await(() => this.innerStream.ReadAsync(buffer, offset, count, cancellationToken)); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => this.Await(() => this.innerStream.WriteAsync(buffer, offset, count, cancellationToken)); + + public override void WriteByte(byte value) => this.Await(() => this.innerStream.WriteByte(value)); + + public override int ReadByte() => this.Await(() => this.innerStream.ReadByte()); + + protected override void Dispose(bool disposing) => this.innerStream.Dispose(); + +#if NETCOREAPP + public override void CopyTo(Stream destination, int bufferSize) => this.Await(() => this.innerStream.CopyTo(destination, bufferSize)); + + public override int Read(Span buffer) + { + this.Wait(); + return this.innerStream.Read(buffer); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => this.Await(() => this.innerStream.ReadAsync(buffer, cancellationToken)); + + public override void Write(ReadOnlySpan buffer) + { + this.Wait(); + this.innerStream.Write(buffer); + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => this.Await(() => this.innerStream.WriteAsync(buffer, cancellationToken)); +#endif + } +} diff --git a/tests/ImageSharp.Tests/TestUtilities/SingleStreamFileSystem.cs b/tests/ImageSharp.Tests/TestUtilities/SingleStreamFileSystem.cs new file mode 100644 index 000000000..ddd1ec750 --- /dev/null +++ b/tests/ImageSharp.Tests/TestUtilities/SingleStreamFileSystem.cs @@ -0,0 +1,19 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using System.IO; +using SixLabors.ImageSharp.IO; + +namespace SixLabors.ImageSharp.Tests.TestUtilities +{ + internal class SingleStreamFileSystem : IFileSystem + { + private readonly Stream stream; + + public SingleStreamFileSystem(Stream stream) => this.stream = stream; + + Stream IFileSystem.Create(string path) => this.stream; + + Stream IFileSystem.OpenRead(string path) => this.stream; + } +}