From b6f00c1597ee053f28612b5185b7b1218a4f050d Mon Sep 17 00:00:00 2001 From: Scott Williams Date: Fri, 28 May 2021 21:27:20 +0100 Subject: [PATCH] Stop using timing to test cancellation token compliance. --- .../Formats/Jpg/JpegDecoderTests.cs | 83 +++++----- .../Formats/Jpg/JpegEncoderTests.cs | 39 ++--- .../Image/ImageTests.SaveAsync.cs | 20 ++- .../AsyncLocalSwitchableFilesystem.cs | 51 +++++++ .../TestUtilities/PausedStream.cs | 142 ++++++++++++++++++ 5 files changed, 269 insertions(+), 66 deletions(-) create mode 100644 tests/ImageSharp.Tests/TestUtilities/AsyncLocalSwitchableFilesystem.cs create mode 100644 tests/ImageSharp.Tests/TestUtilities/PausedStream.cs diff --git a/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs b/tests/ImageSharp.Tests/Formats/Jpg/JpegDecoderTests.cs index 27d70fd188..1b561c20d7 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; @@ -126,61 +127,53 @@ namespace SixLabors.ImageSharp.Tests.Formats.Jpg Assert.IsType(ex.InnerException); } - [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) + [Fact] + public async Task Decode_IsCancellable() { - // 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; + var file = Path.Combine(TestEnvironment.InputImagesDirectoryFullPath, TestImages.Jpeg.Baseline.Jpeg420Small); + using var pausedStream = new PausedStream(file); + var cts = new CancellationTokenSource(); - for (int i = 0; i < numberOfRuns; i++) + var testTask = Task.Run(async () => { - var cts = new CancellationTokenSource(); - if (cancellationDelayMs == 0) - { - cts.Cancel(); - } - else - { - cts.CancelAfter(cancellationDelayMs); - } + AsyncLocalSwitchableFilesystem.ConfigureFileSystemStream(pausedStream); - try - { - using Image image = await Image.LoadAsync(hugeFile, cts.Token); - } - catch (TaskCanceledException) + return await Assert.ThrowsAsync(async () => { - // Successfully observed a cancellation - return; - } - } + using Image image = await Image.LoadAsync("someFakeFile", cts.Token); + }); + }); + + await pausedStream.FirstWaitReached; + cts.Cancel(); - throw new Exception($"No cancellation happened out of {numberOfRuns} runs!"); + // allow testTask to try and continue now we know we have started but canceled + pausedStream.Release(); + + await testTask; } - [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 file = Path.Combine(TestEnvironment.InputImagesDirectoryFullPath, TestImages.Jpeg.Baseline.Jpeg420Small); + using var pausedStream = new PausedStream(file); var cts = new CancellationTokenSource(); - cts.CancelAfter(TimeSpan.FromTicks(1)); - await Assert.ThrowsAsync(() => Image.IdentifyAsync(file, cts.Token)); + + var testTask = Task.Run(async () => + { + AsyncLocalSwitchableFilesystem.ConfigureFileSystemStream(pausedStream); + + return await Assert.ThrowsAsync(async () => await Image.IdentifyAsync("someFakeFile", cts.Token)); + }); + + await pausedStream.FirstWaitReached; + cts.Cancel(); + + // allow testTask to try and continue now we know we have started but canceled + pausedStream.Release(); + + await testTask; } // DEBUG ONLY! diff --git a/tests/ImageSharp.Tests/Formats/Jpg/JpegEncoderTests.cs b/tests/ImageSharp.Tests/Formats/Jpg/JpegEncoderTests.cs index 9a1d423a6d..d7e77eabec 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; @@ -310,28 +311,30 @@ namespace SixLabors.ImageSharp.Tests.Formats.Jpg } [Theory] - [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) + [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(); + using var pausedStream = new PausedStream(new MemoryStream()); var cts = new CancellationTokenSource(); - if (cancellationDelayMs == 0) - { - cts.Cancel(); - } - else + + var testTask = Task.Run(async () => { - cts.CancelAfter(cancellationDelayMs); - } + using var image = new Image(5000, 5000); + return await Assert.ThrowsAsync(async () => + { + var encoder = new JpegEncoder() { Subsample = subsample }; + await image.SaveAsync(pausedStream, encoder, cts.Token); + }); + }); + + await pausedStream.FirstWaitReached; + cts.Cancel(); + + // allow testTask to try and continue now we know we have started but canceled + pausedStream.Release(); - var encoder = new JpegEncoder() { Subsample = subsample }; - await Assert.ThrowsAsync(() => image.SaveAsync(stream, encoder, cts.Token)); + await testTask; } } } diff --git a/tests/ImageSharp.Tests/Image/ImageTests.SaveAsync.cs b/tests/ImageSharp.Tests/Image/ImageTests.SaveAsync.cs index 825bd55e47..f34b74f899 100644 --- a/tests/ImageSharp.Tests/Image/ImageTests.SaveAsync.cs +++ b/tests/ImageSharp.Tests/Image/ImageTests.SaveAsync.cs @@ -139,11 +139,25 @@ namespace SixLabors.ImageSharp.Tests var encoder = new PngEncoder() { CompressionLevel = PngCompressionLevel.BestCompression }; using var stream = new MemoryStream(); var asyncStream = new AsyncStreamWrapper(stream, () => false); + var pausedStream = new PausedStream(asyncStream); + var cts = new CancellationTokenSource(); - cts.CancelAfter(TimeSpan.FromTicks(1)); - await Assert.ThrowsAnyAsync(() => - image.SaveAsync(asyncStream, encoder, cts.Token)); + var testTask = Task.Run(async () => + { + AsyncLocalSwitchableFilesystem.ConfigureFileSystemStream(pausedStream); + + using var image = new Image(5000, 5000); + return await Assert.ThrowsAsync(async () => await image.SaveAsync(pausedStream, encoder, cts.Token)); + }); + + await pausedStream.FirstWaitReached; + cts.Cancel(); + + // allow testTask to try and continue now we know we have started but canceled + pausedStream.Release(); + + await testTask; } } } diff --git a/tests/ImageSharp.Tests/TestUtilities/AsyncLocalSwitchableFilesystem.cs b/tests/ImageSharp.Tests/TestUtilities/AsyncLocalSwitchableFilesystem.cs new file mode 100644 index 0000000000..d53af7a43a --- /dev/null +++ b/tests/ImageSharp.Tests/TestUtilities/AsyncLocalSwitchableFilesystem.cs @@ -0,0 +1,51 @@ +// Copyright (c) Six Labors. +// Licensed under the Apache License, Version 2.0. + +using System.IO; +using System.Threading; +using SixLabors.ImageSharp.IO; + +namespace SixLabors.ImageSharp.Tests.TestUtilities +{ + public class AsyncLocalSwitchableFilesystem : IFileSystem + { + private static readonly LocalFileSystem LocalFile = new LocalFileSystem(); + + private static readonly AsyncLocalSwitchableFilesystem Instance = new AsyncLocalSwitchableFilesystem(); + + internal static void ConfigureDefaultFileSystem(IFileSystem fileSystem) + { + Configuration.Default.FileSystem = Instance; + Instance.FileSystem = fileSystem; + } + + internal static void ConfigureFileSystemStream(Stream stream) + { + Configuration.Default.FileSystem = Instance; + Instance.FileSystem = new SingleStreamFileSystem(stream); + } + + private readonly AsyncLocal asyncLocal = new AsyncLocal(); + + private IFileSystem FileSystem + { + get => this.asyncLocal.Value ?? LocalFile; + set => this.asyncLocal.Value = value; + } + + public Stream Create(string path) => this.FileSystem.Create(path); + + public Stream OpenRead(string path) => this.FileSystem.OpenRead(path); + + public 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; + } + } +} diff --git a/tests/ImageSharp.Tests/TestUtilities/PausedStream.cs b/tests/ImageSharp.Tests/TestUtilities/PausedStream.cs new file mode 100644 index 0000000000..20c4cf0e35 --- /dev/null +++ b/tests/ImageSharp.Tests/TestUtilities/PausedStream.cs @@ -0,0 +1,142 @@ +// 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 slim = new SemaphoreSlim(0); + + private readonly CancellationTokenSource cancelationTokenSource = new CancellationTokenSource(); + private readonly TaskCompletionSource waitReached = new TaskCompletionSource(); + + private readonly Stream innerStream; + + public Task FirstWaitReached => this.waitReached.Task; + + public void Release() + { + this.slim.Release(); + this.cancelationTokenSource.Cancel(); + } + + public void Next() => this.slim.Release(); + + private void Wait() + { + this.waitReached.TrySetResult(null); + + if (this.cancelationTokenSource.IsCancellationRequested) + { + return; + } + + try + { + this.slim.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 void CopyTo(Stream destination, int bufferSize) => this.Await(() => this.innerStream.CopyTo(destination, bufferSize)); + + 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 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 Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => this.Await(() => this.innerStream.WriteAsync(buffer, offset, count, cancellationToken)); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) => this.Await(() => this.innerStream.WriteAsync(buffer, 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(); + public override ValueTask DisposeAsync() => this.innerStream.DisposeAsync(); + } +}