From 3fddb89860842ffc836a0d0f69b161f67e4aa7c4 Mon Sep 17 00:00:00 2001 From: madelson <1269046+madelson@users.noreply.github.com> Date: Sun, 15 Nov 2020 14:31:17 -0500 Subject: [PATCH 01/28] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 15bfb20..e753fac 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Linux: [![Build Status](https://travis-ci.com/madelson/MedallionShell.svg?branch ## Release Notes - 1.6.2 - - Add net471 build as workaround for [#75](https://github.com/madelson/MedallionShell/issues/75) + - Add net471 build as workaround for [#75](https://github.com/madelson/MedallionShell/issues/75). Thanks [Cloudmersive](https://github.com/Cloudmersive) for reporting the issue and testing the fix! - 1.6.1 - Strong-named release [MedallionShell.StrongName](https://www.nuget.org/packages/medallionshell.strongname) ([#65](https://github.com/madelson/MedallionShell/issues/65)). Thanks [ldennington](https://github.com/ldennington)! - Fixes transient error in signaling on Windows machines with slow disks ([#61](https://github.com/madelson/MedallionShell/issues/61)) @@ -135,7 +135,7 @@ Linux: [![Build Status](https://travis-ci.com/madelson/MedallionShell.svg?branch - Add .NET Standard 2.0 and .NET 4.6 build targets so that users of more modern frameworks can take advantage of more modern APIs. The .NET Standard 1.3 and .NET 4.5 targets will likely be retired in the event of a 2.0 release. - Allow for setting piping and redirection via a `Shell` option with the new `Command(Func)` option ([#39](https://github.com/madelson/MedallionShell/issues/39)) - Add CI testing for Mono and .NET Core on Linux -- 1.5.1 Improves Mono.Android compatibility ([#22](https://github.com/madelson/MedallionShell/issues/22)). Thanks [sushihangover](https://github.com/sushihangover) for reporting and testing the fix! +- 1.5.1 Improves Mono.Android compatibility ([#22](https://github.com/madelson/MedallionShell/issues/22)). Thanks [sushihangover](https://github.com/sushihangover) for reporting the issue and testing the fix! - 1.5.0 - Command overrides `ToString()` to simplify debugging ([#19](https://github.com/madelson/MedallionShell/issues/19)). Thanks [Stephanvs](https://github.com/Stephanvs)! - WindowsCommandLineSyntax no longer quotes arguments that don't require it From 15815ce7a1a4c6611e1967ace54a4a545b8b642a Mon Sep 17 00:00:00 2001 From: madelson <1269046+madelson@users.noreply.github.com> Date: Fri, 10 Mar 2023 07:00:37 -0500 Subject: [PATCH 02/28] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e753fac..85e0352 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -MedallionShell +MedallionShell [![Tests status](https://appveyor-shields-badge.herokuapp.com/api/api/testResults/madelson/MedallionShell/badge.svg)](https://ci.appveyor.com/project/madelson/MedallionShell) + ============== MedallionShell vastly simplifies working with processes in .NET. From ca60f2a6a39e51d39c62f5f5079aa48716a29862 Mon Sep 17 00:00:00 2001 From: madelson <1269046+madelson@users.noreply.github.com> Date: Fri, 10 Mar 2023 07:00:54 -0500 Subject: [PATCH 03/28] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 85e0352..9c077b8 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -MedallionShell [![Tests status](https://appveyor-shields-badge.herokuapp.com/api/api/testResults/madelson/MedallionShell/badge.svg)](https://ci.appveyor.com/project/madelson/MedallionShell) +MedallionShell ============== From da4563576035d22c0ede74a0345611770f4f9fb4 Mon Sep 17 00:00:00 2001 From: madelson <1269046+madelson@users.noreply.github.com> Date: Fri, 10 Mar 2023 07:03:20 -0500 Subject: [PATCH 04/28] Update README.md --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 9c077b8..8f7885d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,4 @@ -MedallionShell - -============== +# MedallionShell MedallionShell vastly simplifies working with processes in .NET. From e45f1f6ffd4cde752bc21753c476a6f7cac0f01d Mon Sep 17 00:00:00 2001 From: madelson <1269046+madelson@users.noreply.github.com> Date: Tue, 4 Apr 2023 07:06:43 -0400 Subject: [PATCH 05/28] Update README.md Clarify readme in response to #103 --- README.md | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8f7885d..55a33c7 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,30 @@ Most APIs create a `Command` instance by starting a new process. However, you ca ### Standard IO -One of the main ways to interact with a process is via its [standard IO streams](https://en.wikipedia.org/wiki/Standard_streams) (in, out and error). By default, MedallionShell configures the process to enable these streams and captures standard error and standard output in the `Command`'s result. +One of the main ways to interact with a process is via its [standard IO streams](https://en.wikipedia.org/wiki/Standard_streams) (in, out and error). By default, MedallionShell configures the process to enable these streams and captures standard error and standard output in the `Command`'s result: +```C# +var command = Command.Run(...); +var result = await command.Task; +Console.WriteLine($"{result.StandardOutput}, {result.StandardError}"); +``` + +If you want to consume the output (stdout and stderr) as a _merged_ stream of lines like you would see in the console, you can use the `GetOutputAndErrorLines()` method: +```C# +var command = Command.Run(...); +foreach (var line in command.GetOutputAndErrorLines()) +{ + Console.WriteLine(line); +} +``` -Additionally/alternatively, you can interact with these streams directly via the `Command.StandardInput`, `Command.StandardOutput`, and `Command.StandardError` properties. As with `Process`, these are `TextWriter`/`TextReader` objects that also expose the underlying `Stream`, giving you the option of writing/reading either text or raw bytes. +Additionally/alternatively, you can interact with these streams directly via the `Command.StandardInput`, `Command.StandardOutput`, and `Command.StandardError` properties. As with `Process`, these are `TextWriter`/`TextReader` objects that also expose the underlying `Stream`, giving you the option of writing/reading either text or raw bytes: +```C# +var command = Command.Run(...); +command.StandardInput.Write("some text"); // e.g. write as text +command.StandardInput.BaseStream.Write(new byte[100]); // e.g. write as bytes +command.StandardOutput.ReadLine(); // e.g. read as text +command.StandardError.BaseStream.Read(new byte[100]); // e.g. read as bytes +``` The standard IO streams also contain methods for piping to and from common sinks and sources, including `Stream`s, `TextReader/Writer`s, files, and collections. For example: ```C# @@ -69,6 +90,8 @@ await Command.Run("ProcssingStep1.exe") < new FileInfo("input.txt") | Command.Run("processingStep2.exe") > new FileInfo("output.text"); ``` +Finally, note that **any content you read directly will _not_ end up in the result; the `result.StandardOutput` and `result.StandardError` properties store only content that you have not already consumed via some other mechanism. + ### Stopping a Command You can immediately terminate a command with the `Kill()` API. You can also use the `TrySignalAsync` API to send other types of signals which can allow for graceful shutdown if the target process handles them. `CommandSignal.ControlC` works across platforms, while other signals are OS-specific. From 87316dfa0e811d380d284c384a1bf525832ede7e Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sun, 25 Feb 2024 19:32:49 -0500 Subject: [PATCH 06/28] Close #98: Have MergedLinesEnumerable implement IAsyncEnumerable --- MedallionShell.Tests/AttachingTests.cs | 2 +- MedallionShell.Tests/GeneralTest.cs | 18 +-- MedallionShell.Tests/IoCommandTest.cs | 10 +- .../MedallionShell.Tests.csproj | 20 ++- .../Streams/MergedLinesEnumerableTestAsync.cs | 136 ++++++++++++++++++ MedallionShell/MedallionShell.csproj | 3 + .../Streams/MergedLinesEnumerable.cs | 80 +++++++++++ SampleCommand/SampleCommand.csproj | 3 +- stylecop.analyzers.ruleset | 10 +- 9 files changed, 257 insertions(+), 25 deletions(-) create mode 100644 MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs diff --git a/MedallionShell.Tests/AttachingTests.cs b/MedallionShell.Tests/AttachingTests.cs index c28afa3..fd2f50a 100644 --- a/MedallionShell.Tests/AttachingTests.cs +++ b/MedallionShell.Tests/AttachingTests.cs @@ -106,7 +106,7 @@ public void TestTimeout() "Did not time out" ); - Assert.IsInstanceOf(exception.GetBaseException()); + Assert.IsInstanceOf(exception!.GetBaseException()); } } } diff --git a/MedallionShell.Tests/GeneralTest.cs b/MedallionShell.Tests/GeneralTest.cs index 125d7fa..09ad6bb 100644 --- a/MedallionShell.Tests/GeneralTest.cs +++ b/MedallionShell.Tests/GeneralTest.cs @@ -112,7 +112,7 @@ public void TestExitCode() var shell = MakeTestShell(o => o.ThrowOnError()); var ex = Assert.Throws(() => shell.Run(SampleCommand, "exit", -1).Task.Wait()); - ex.InnerExceptions.Select(e => e.GetType()).SequenceEqual(new[] { typeof(ErrorExitCodeException) }) + ex!.InnerExceptions.Select(e => e.GetType()).SequenceEqual(new[] { typeof(ErrorExitCodeException) }) .ShouldEqual(true); shell.Run(SampleCommand, "exit", 0).Task.Wait(); @@ -123,7 +123,7 @@ public void TestThrowOnErrorWithTimeout() { var command = TestShell.Run(SampleCommand, new object[] { "exit", 1 }, o => o.ThrowOnError().Timeout(TimeSpan.FromDays(1))); var ex = Assert.Throws(() => command.Task.Wait()); - ex.InnerExceptions.Select(e => e.GetType()).SequenceEqual(new[] { typeof(ErrorExitCodeException) }) + ex!.InnerExceptions.Select(e => e.GetType()).SequenceEqual(new[] { typeof(ErrorExitCodeException) }) .ShouldEqual(true); } @@ -132,7 +132,7 @@ public void TestTimeout() { var willTimeout = TestShell.Run(SampleCommand, new object[] { "sleep", 1000000 }, o => o.Timeout(TimeSpan.FromMilliseconds(200))); var ex = Assert.Throws(() => willTimeout.Task.Wait()); - Assert.IsInstanceOf(ex.InnerException); + Assert.IsInstanceOf(ex!.InnerException); } [Test] @@ -140,7 +140,7 @@ public void TestZeroTimeout() { var willTimeout = TestShell.Run(SampleCommand, new object[] { "sleep", 1000000 }, o => o.Timeout(TimeSpan.Zero)); var ex = Assert.Throws(() => willTimeout.Task.Wait()); - Assert.IsInstanceOf(ex.InnerException); + Assert.IsInstanceOf(ex!.InnerException); } [Test] @@ -177,7 +177,7 @@ public void TestCancellationCanceledPartway() results.Count.ShouldEqual(1); cancellationTokenSource.Cancel(); var aggregateException = Assert.Throws(() => command.Task.Wait(1000)); - Assert.IsInstanceOf(aggregateException.GetBaseException()); + Assert.IsInstanceOf(aggregateException!.GetBaseException()); CollectionAssert.AreEqual(results, new[] { "hello" }); } @@ -350,7 +350,7 @@ public void TestVersioning() var version = typeof(Command).GetTypeInfo().Assembly.GetName().Version.ToString(); var informationalVersion = (AssemblyInformationalVersionAttribute)typeof(Command).GetTypeInfo().Assembly.GetCustomAttribute(typeof(AssemblyInformationalVersionAttribute)); Assert.IsNotNull(informationalVersion); - version.ShouldEqual(Regex.Replace(informationalVersion.InformationalVersion, "-.*$", string.Empty) + ".0"); + version.ShouldEqual(Regex.Replace(informationalVersion.InformationalVersion, "[+-].*$", string.Empty) + ".0"); } [Test] @@ -469,11 +469,11 @@ void TestHelper(bool disposeOnExit) if (disposeOnExit) { // invalid due to DisposeOnExit() - Assert.Throws(() => command1.Process.ToString()) + Assert.Throws(() => command1.Process.ToString())! .Message.ShouldContain("dispose on exit"); - Assert.Throws(() => command2.Processes.Count()) + Assert.Throws(() => command2.Processes.Count())! .Message.ShouldContain("dispose on exit"); - Assert.Throws(() => pipeCommand.Processes.Count()) + Assert.Throws(() => pipeCommand.Processes.Count())! .Message.ShouldContain("dispose on exit"); } else diff --git a/MedallionShell.Tests/IoCommandTest.cs b/MedallionShell.Tests/IoCommandTest.cs index 330632c..72fc438 100644 --- a/MedallionShell.Tests/IoCommandTest.cs +++ b/MedallionShell.Tests/IoCommandTest.cs @@ -19,12 +19,12 @@ public void TestStandardOutCannotBeAccessedAfterRedirectingIt() var command = TestShell.Run(SampleCommand, "argecho", "a"); var ioCommand = command.RedirectTo(output); - var errorMessage = Assert.Throws(() => ioCommand.StandardOutput.GetHashCode()).Message; + var errorMessage = Assert.Throws(() => ioCommand.StandardOutput.GetHashCode())!.Message; errorMessage.ShouldEqual("StandardOutput is unavailable because it is already being piped to System.Collections.Generic.List`1[System.String]"); Assert.DoesNotThrow(() => command.StandardOutput.GetHashCode()); - Assert.Throws(() => ioCommand.Result.StandardOutput.GetHashCode()) + Assert.Throws(() => ioCommand.Result.StandardOutput.GetHashCode())! .Message .ShouldEqual(errorMessage); Assert.Throws(() => command.Result.StandardOutput.GetHashCode()); @@ -40,12 +40,12 @@ public void TestStandardErrorCannotBeAccessedAfterRedirectingIt() var command = TestShell.Run(SampleCommand, "argecho", "a"); var ioCommand = command.RedirectStandardErrorTo(output); - var errorMessage = Assert.Throws(() => ioCommand.StandardError.GetHashCode()).Message; + var errorMessage = Assert.Throws(() => ioCommand.StandardError.GetHashCode())!.Message; errorMessage.ShouldEqual("StandardError is unavailable because it is already being piped to System.Collections.Generic.List`1[System.String]"); Assert.DoesNotThrow(() => command.StandardError.GetHashCode()); - Assert.Throws(() => ioCommand.Result.StandardError.GetHashCode()) + Assert.Throws(() => ioCommand.Result.StandardError.GetHashCode())! .Message .ShouldEqual(errorMessage); Assert.Throws(() => command.Result.StandardError.GetHashCode()); @@ -60,7 +60,7 @@ public void TestStandardInputCannotBeAccessedAfterRedirectingIt() var command = TestShell.Run(SampleCommand, "echo"); var ioCommand = command.RedirectFrom(new[] { "a" }); - var errorMessage = Assert.Throws(() => ioCommand.StandardInput.GetHashCode()).Message; + var errorMessage = Assert.Throws(() => ioCommand.StandardInput.GetHashCode())!.Message; errorMessage.ShouldEqual("StandardInput is unavailable because it is already being piped from System.String[]"); Assert.DoesNotThrow(() => command.StandardInput.GetHashCode()); diff --git a/MedallionShell.Tests/MedallionShell.Tests.csproj b/MedallionShell.Tests/MedallionShell.Tests.csproj index b4fe639..dee059f 100644 --- a/MedallionShell.Tests/MedallionShell.Tests.csproj +++ b/MedallionShell.Tests/MedallionShell.Tests.csproj @@ -8,15 +8,16 @@ True ..\stylecop.analyzers.ruleset true - 1591 + 1591,NU1902,NU1903 + false - - + + - - + + All @@ -34,5 +35,14 @@ + + + + 8.0.0 + + + 6.0.1 + + diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs new file mode 100644 index 0000000..e3cda55 --- /dev/null +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs @@ -0,0 +1,136 @@ +#if NETCOREAPP2_2_OR_GREATER +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Medallion.Shell.Streams; +using Moq; +using NUnit.Framework; + +namespace Medallion.Shell.Tests.Streams +{ + public class MergedLinesEnumerableTestAsync + { + [Test] + public async Task TestOneIsEmpty() + { + var empty1 = new StringReader(string.Empty); + var nonEmpty1 = new StringReader("abc\r\ndef\r\nghi\r\njkl"); + + IAsyncEnumerable asyncEnumerable1 = new MergedLinesEnumerable(empty1, nonEmpty1); + var list1 = await asyncEnumerable1.ToListAsync(); + list1.SequenceEqual(["abc", "def", "ghi", "jkl"]) + .ShouldEqual(true, string.Join(", ", list1)); + + var empty2 = new StringReader(string.Empty); + var nonEmpty2 = new StringReader("a\nbb\nccc\n"); + var asyncEnumerable2 = new MergedLinesEnumerable(nonEmpty2, empty2); + var list2 = await asyncEnumerable2.ToListAsync(); + list2.SequenceEqual(["a", "bb", "ccc"]) + .ShouldEqual(true, string.Join(", ", list2)); + } + + [Test] + public async Task TestBothAreEmpty() + { + var list = await new MergedLinesEnumerable(new StringReader(string.Empty), new StringReader(string.Empty)).ToListAsync(); + list.Count.ShouldEqual(0, string.Join(", ", list)); + } + + [Test] + public async Task TestBothArePopulatedEqualSizes() + { + var list = await new MergedLinesEnumerable( + new StringReader("a\nbb\nccc"), + new StringReader("1\r\n22\r\n333") + ) + .ToListAsync(); + string.Join(", ", list).ShouldEqual("a, 1, bb, 22, ccc, 333"); + } + + [Test] + public async Task TestBothArePopulatedDifferenceSizes() + { + var lines1 = string.Join("\n", ["x", "y", "z"]); + var lines2 = string.Join("\n", ["1", "2", "3", "4", "5"]); + + var list1 = await new MergedLinesEnumerable(new StringReader(lines1), new StringReader(lines2)) + .ToListAsync(); + string.Join(", ", list1).ShouldEqual("x, 1, y, 2, z, 3, 4, 5"); + + var list2 = await new MergedLinesEnumerable(new StringReader(lines2), new StringReader(lines1)) + .ToListAsync(); + string.Join(", ", list2).ShouldEqual("1, x, 2, y, 3, z, 4, 5"); + } + + [Test] + public void TestConsumeTwice() + { + var asyncEnumerable = new MergedLinesEnumerable(new StringReader("a"), new StringReader("b")); + asyncEnumerable.GetAsyncEnumerator(); + Assert.Throws(() => asyncEnumerable.GetAsyncEnumerator()); + } + + [Test] + public void TestOneThrows() + { + void TestOneThrows(bool reverse) + { + var reader1 = new StringReader("a\nb\nc"); + var count = 0; + var mockReader = new Mock(MockBehavior.Strict); + mockReader.Setup(r => r.ReadLineAsync()) + .ReturnsAsync(() => ++count < 3 ? "LINE" : throw new TimeZoneNotFoundException()); + + Assert.ThrowsAsync( + async () => await new MergedLinesEnumerable( + reverse ? mockReader.Object : reader1, + reverse ? reader1 : mockReader.Object + ) + .ToListAsync() + ); + } + + TestOneThrows(reverse: false); + TestOneThrows(reverse: true); + } + + [Test] + public async Task FuzzTest() + { + var pipe1 = new Pipe(); + var pipe2 = new Pipe(); + + var asyncEnumerable = new MergedLinesEnumerable(new StreamReader(pipe1.OutputStream), new StreamReader(pipe2.OutputStream)); + + var strings1 = await AsyncEnumerable.Range(0, 2000).Select(_ => Guid.NewGuid().ToString()).ToArrayAsync(); + var strings2 = await AsyncEnumerable.Range(0, 2300).Select(_ => Guid.NewGuid().ToString()).ToArrayAsync(); + + static void WriteStrings(IReadOnlyList strings, Pipe pipe) + { + var spinWait = default(SpinWait); + var random = new Random(Guid.NewGuid().GetHashCode()); + using var writer = new StreamWriter(pipe.InputStream); + foreach (var line in strings) + { + if (random.Next(4) == 1) + { + spinWait.SpinOnce(); + } + + writer.WriteLine(line); + } + } + + var task1 = Task.Run(() => WriteStrings(strings1, pipe1)); + var task2 = Task.Run(() => WriteStrings(strings2, pipe2)); + var consumeTask = Task.Run(async () => await asyncEnumerable.ToListAsync()); + Task.WaitAll(task1, task2, consumeTask); + + CollectionAssert.AreEquivalent(strings1.Concat(strings2).ToList(), await consumeTask); + } + } +} +#endif diff --git a/MedallionShell/MedallionShell.csproj b/MedallionShell/MedallionShell.csproj index e16fbcf..d4a8314 100644 --- a/MedallionShell/MedallionShell.csproj +++ b/MedallionShell/MedallionShell.csproj @@ -66,4 +66,7 @@ MedallionShell.ProcessSignaler.exe + + + \ No newline at end of file diff --git a/MedallionShell/Streams/MergedLinesEnumerable.cs b/MedallionShell/Streams/MergedLinesEnumerable.cs index 267bb5d..5369d3b 100644 --- a/MedallionShell/Streams/MergedLinesEnumerable.cs +++ b/MedallionShell/Streams/MergedLinesEnumerable.cs @@ -10,6 +10,12 @@ namespace Medallion.Shell.Streams { internal sealed class MergedLinesEnumerable : IEnumerable +#if NETSTANDARD2_0_OR_GREATER +// SA1001 Commas should not be preceded by whitespace. +#pragma warning disable SA1001 + , IAsyncEnumerable +#pragma warning restore SA1001 +#endif { private readonly TextReader standardOutput, standardError; private int consumed; @@ -20,6 +26,18 @@ public MergedLinesEnumerable(TextReader standardOutput, TextReader standardError this.standardError = standardError; } +#if NETSTANDARD2_0_OR_GREATER + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + Throw.If( + Interlocked.Exchange(ref this.consumed, 1) != 0, + "The enumerable returned by GetOutputAndErrorLines() can only be enumerated once" + ); + + return this.GetAsyncEnumeratorInternal(); + } +#endif + public IEnumerator GetEnumerator() { Throw.If( @@ -92,6 +110,68 @@ private IEnumerator GetEnumeratorInternal() } } +#if NETSTANDARD2_0_OR_GREATER + private async IAsyncEnumerator GetAsyncEnumeratorInternal() + { + var tasks = new List(capacity: 2) + { + new(this.standardOutput), + new(this.standardError), + }; + + // phase 1: read both streams simultaneously, alternating between which is given priority. + // Stop when one (or both) streams is exhausted + + TextReader remaining; + while (true) + { + ReaderAndTask next; + if (tasks[0].Task.IsCompleted) + { + next = tasks[0]; + } + else if (tasks[1].Task.IsCompleted) + { + next = tasks[1]; + } + else + { + var nextCompleted = await Task.WhenAny(tasks.Select(t => t.Task)); + next = tasks[0].Task == nextCompleted ? tasks[0] : tasks[1]; + } + + var nextLine = await next.Task; + tasks.Remove(next); + + if (nextLine != null) + { + yield return nextLine; + tasks.Add(new ReaderAndTask(next.Reader)); + } + else + { + var otherAsyncLine = await tasks[0].Task; + if (otherAsyncLine != null) + { + yield return otherAsyncLine; + remaining = tasks[0].Reader; + break; + } + else + { + yield break; + } + } + } + + // phase 2: finish reading the remaining stream + while (remaining.ReadLine() is { } line) + { + yield return line; + } + } +#endif + private struct ReaderAndTask : IEquatable { public ReaderAndTask(TextReader reader) diff --git a/SampleCommand/SampleCommand.csproj b/SampleCommand/SampleCommand.csproj index 9d9b025..c036430 100644 --- a/SampleCommand/SampleCommand.csproj +++ b/SampleCommand/SampleCommand.csproj @@ -9,7 +9,8 @@ True ..\stylecop.analyzers.ruleset true - 1591 + 1591,NU1902,NU1903 + false diff --git a/stylecop.analyzers.ruleset b/stylecop.analyzers.ruleset index 72177f7..3360a38 100644 --- a/stylecop.analyzers.ruleset +++ b/stylecop.analyzers.ruleset @@ -1,16 +1,18 @@  - - + + - + + + - + From d3720b9b520f18c18b36c0bf3afaf7fb6903e83d Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Wed, 6 Mar 2024 20:49:13 -0500 Subject: [PATCH 07/28] Fix spaces, preprocessor directives, pull out common assertions, Add .ConfigureAwait(false), fix formatting of PackageReferences --- .../MedallionShell.Tests.csproj | 19 ++++-------- .../Streams/MergedLinesEnumerableTestAsync.cs | 2 +- .../Streams/MergedLinesEnumerable.cs | 29 +++++++++---------- SampleCommand/SampleCommand.csproj | 3 +- stylecop.analyzers.ruleset | 2 +- 5 files changed, 23 insertions(+), 32 deletions(-) diff --git a/MedallionShell.Tests/MedallionShell.Tests.csproj b/MedallionShell.Tests/MedallionShell.Tests.csproj index 878a48a..df97030 100644 --- a/MedallionShell.Tests/MedallionShell.Tests.csproj +++ b/MedallionShell.Tests/MedallionShell.Tests.csproj @@ -6,9 +6,9 @@ false Latest enable - True - ..\stylecop.analyzers.ruleset - true + True + ..\stylecop.analyzers.ruleset + true 1591 Medallion.Shell.Tests @@ -18,6 +18,8 @@ + + All @@ -25,7 +27,7 @@ - + @@ -33,14 +35,5 @@ - - - - 8.0.0 - - - 6.0.1 - - diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs index e3cda55..369c9fc 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs @@ -1,4 +1,4 @@ -#if NETCOREAPP2_2_OR_GREATER +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER using System; using System.Collections.Generic; using System.IO; diff --git a/MedallionShell/Streams/MergedLinesEnumerable.cs b/MedallionShell/Streams/MergedLinesEnumerable.cs index 5d54fca..1058495 100644 --- a/MedallionShell/Streams/MergedLinesEnumerable.cs +++ b/MedallionShell/Streams/MergedLinesEnumerable.cs @@ -3,17 +3,16 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; namespace Medallion.Shell.Streams { internal sealed class MergedLinesEnumerable : IEnumerable -#if NETSTANDARD2_0_OR_GREATER +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER // SA1001 Commas should not be preceded by whitespace. #pragma warning disable SA1001 - , IAsyncEnumerable + , IAsyncEnumerable #pragma warning restore SA1001 #endif { @@ -26,13 +25,10 @@ public MergedLinesEnumerable(TextReader standardOutput, TextReader standardError this.standardError = standardError; } -#if NETSTANDARD2_0_OR_GREATER +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { - Throw.If( - Interlocked.Exchange(ref this.consumed, 1) != 0, - "The enumerable returned by GetOutputAndErrorLines() can only be enumerated once" - ); + this.AssertNoMultipleEnumeration(); return this.GetAsyncEnumeratorInternal(); } @@ -40,14 +36,17 @@ public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellatio public IEnumerator GetEnumerator() { + this.AssertNoMultipleEnumeration(); + + return this.GetEnumeratorInternal(); + } + + private void AssertNoMultipleEnumeration() => Throw.If( Interlocked.Exchange(ref this.consumed, 1) != 0, "The enumerable returned by GetOutputAndErrorLines() can only be enumerated once" ); - return this.GetEnumeratorInternal(); - } - IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); private IEnumerator GetEnumeratorInternal() @@ -110,7 +109,7 @@ private IEnumerator GetEnumeratorInternal() } } -#if NETSTANDARD2_0_OR_GREATER +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER private async IAsyncEnumerator GetAsyncEnumeratorInternal() { var tasks = new List(capacity: 2) @@ -136,11 +135,11 @@ private async IAsyncEnumerator GetAsyncEnumeratorInternal() } else { - var nextCompleted = await Task.WhenAny(tasks.Select(t => t.Task)); + var nextCompleted = await Task.WhenAny(tasks.Select(t => t.Task)).ConfigureAwait(false); next = tasks[0].Task == nextCompleted ? tasks[0] : tasks[1]; } - var nextLine = await next.Task; + var nextLine = await next.Task.ConfigureAwait(false); tasks.Remove(next); if (nextLine != null) @@ -150,7 +149,7 @@ private async IAsyncEnumerator GetAsyncEnumeratorInternal() } else { - var otherAsyncLine = await tasks[0].Task; + var otherAsyncLine = await tasks[0].Task.ConfigureAwait(false); if (otherAsyncLine != null) { yield return otherAsyncLine; diff --git a/SampleCommand/SampleCommand.csproj b/SampleCommand/SampleCommand.csproj index e8d8a3a..0bebdfe 100644 --- a/SampleCommand/SampleCommand.csproj +++ b/SampleCommand/SampleCommand.csproj @@ -10,8 +10,7 @@ True ..\stylecop.analyzers.ruleset true - 1591,NU1902,NU1903 - false + 1591 diff --git a/stylecop.analyzers.ruleset b/stylecop.analyzers.ruleset index 3360a38..47b4531 100644 --- a/stylecop.analyzers.ruleset +++ b/stylecop.analyzers.ruleset @@ -6,7 +6,7 @@ - + From 990605ed4b31e39dc1d7e8feb7827c2fd8774be8 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Wed, 6 Mar 2024 20:49:22 -0500 Subject: [PATCH 08/28] Use var --- MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs index 369c9fc..271041b 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs @@ -19,7 +19,7 @@ public async Task TestOneIsEmpty() var empty1 = new StringReader(string.Empty); var nonEmpty1 = new StringReader("abc\r\ndef\r\nghi\r\njkl"); - IAsyncEnumerable asyncEnumerable1 = new MergedLinesEnumerable(empty1, nonEmpty1); + var asyncEnumerable1 = new MergedLinesEnumerable(empty1, nonEmpty1); var list1 = await asyncEnumerable1.ToListAsync(); list1.SequenceEqual(["abc", "def", "ghi", "jkl"]) .ShouldEqual(true, string.Join(", ", list1)); From 0ab96b04ca15b42eaba41602e9efde7494651bf1 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Wed, 6 Mar 2024 21:24:15 -0500 Subject: [PATCH 09/28] Pass CancellationToken to GetAsyncEnumeratorInternal --- MedallionShell/Streams/MergedLinesEnumerable.cs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/MedallionShell/Streams/MergedLinesEnumerable.cs b/MedallionShell/Streams/MergedLinesEnumerable.cs index 1058495..11e9a46 100644 --- a/MedallionShell/Streams/MergedLinesEnumerable.cs +++ b/MedallionShell/Streams/MergedLinesEnumerable.cs @@ -30,7 +30,7 @@ public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellatio { this.AssertNoMultipleEnumeration(); - return this.GetAsyncEnumeratorInternal(); + return this.GetAsyncEnumeratorInternal(cancellationToken); } #endif @@ -110,7 +110,7 @@ private IEnumerator GetEnumeratorInternal() } #if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER - private async IAsyncEnumerator GetAsyncEnumeratorInternal() + private async IAsyncEnumerator GetAsyncEnumeratorInternal(CancellationToken cancellationToken) { var tasks = new List(capacity: 2) { @@ -164,7 +164,11 @@ private async IAsyncEnumerator GetAsyncEnumeratorInternal() } // phase 2: finish reading the remaining stream +#if NET7_0_OR_GREATER + while (await remaining.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) +#else while (remaining.ReadLine() is { } line) +#endif { yield return line; } @@ -173,10 +177,14 @@ private async IAsyncEnumerator GetAsyncEnumeratorInternal() private struct ReaderAndTask : IEquatable { - public ReaderAndTask(TextReader reader) + public ReaderAndTask(TextReader reader, CancellationToken cancellationToken = default) { this.Reader = reader; +#if NET7_0_OR_GREATER + this.Task = reader.ReadLineAsync(cancellationToken); +#else this.Task = reader.ReadLineAsync(); +#endif } public TextReader Reader { get; } From fdd9137912951540fa937897420c1d7126eee666 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Thu, 7 Mar 2024 22:15:44 -0500 Subject: [PATCH 10/28] Unfixed --- MedallionShell/MedallionShell.csproj | 2 +- .../Streams/MergedLinesEnumerable.cs | 101 ++++++++++++++++-- 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/MedallionShell/MedallionShell.csproj b/MedallionShell/MedallionShell.csproj index f7abe77..b1b33c6 100644 --- a/MedallionShell/MedallionShell.csproj +++ b/MedallionShell/MedallionShell.csproj @@ -80,7 +80,7 @@ MedallionShell.ProcessSignaler.exe - + \ No newline at end of file diff --git a/MedallionShell/Streams/MergedLinesEnumerable.cs b/MedallionShell/Streams/MergedLinesEnumerable.cs index 11e9a46..181c8b2 100644 --- a/MedallionShell/Streams/MergedLinesEnumerable.cs +++ b/MedallionShell/Streams/MergedLinesEnumerable.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; @@ -8,12 +9,11 @@ namespace Medallion.Shell.Streams { - internal sealed class MergedLinesEnumerable : IEnumerable + internal sealed class MergedLinesEnumerable : #if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER -// SA1001 Commas should not be preceded by whitespace. -#pragma warning disable SA1001 - , IAsyncEnumerable -#pragma warning restore SA1001 + IEnumerable, IAsyncEnumerable +#else + IEnumerable #endif { private readonly TextReader standardOutput, standardError; @@ -49,7 +49,7 @@ private void AssertNoMultipleEnumeration() => IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); - private IEnumerator GetEnumeratorInternal() + private IEnumerator GetEnumeratorInternal3() { var tasks = new List(capacity: 2); tasks.Add(new ReaderAndTask(this.standardOutput)); @@ -108,6 +108,67 @@ private IEnumerator GetEnumeratorInternal() yield return line; } } + + private IEnumerator GetEnumeratorInternal() + { + List tasks = []; + + // phase 1: read both streams simultaneously, alternating between which is given priority. + // Stop when both streams are exhausted + do + { + if (this.GetNextLineAsync(tasks).GetAwaiter().GetResult() is { } nextLine) + { + yield return nextLine; + } + else + { + yield break; + } + } + while (tasks.Count != 1); + + // phase 2: finish reading the remaining stream + var remaining = tasks[0].Reader; + while (remaining.ReadLine() is { } line) + { + yield return line; + } + } + + + private async Task GetNextLineAsync(List tasks, CancellationToken cancellationToken = default) + { + Debug.Assert(tasks.Count is 0 or 2, "There should be EITHER nothing OR both stdout and stderr."); + + if (tasks.Count == 0) + { + tasks = [new(this.standardOutput), new(this.standardError)]; + } + + // Figure out which of the 2 tasks is completed. Remove that task and, if the result is not null, replace it + // by queueing up the next read. + // If the result is not null, return the result. If the result is null instead await the other task and return its result. + + var nextCompleted = await Task.WhenAny(tasks.Select(t => t.Task)).ConfigureAwait(false); + var next = tasks[0].Task == nextCompleted ? tasks[0] : tasks[1]; + + var nextLine = await next.Task.ConfigureAwait(false); + tasks.Remove(next); + if (nextLine is { }) + { + tasks.Add(new ReaderAndTask(next.Reader, cancellationToken)); + return nextLine; + } + else if (await tasks[0].Task.ConfigureAwait(false) is { } otherAsyncLine) + { + return otherAsyncLine; + } + else + { + return null; + } + } #if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER private async IAsyncEnumerator GetAsyncEnumeratorInternal(CancellationToken cancellationToken) @@ -168,6 +229,34 @@ private async IAsyncEnumerator GetAsyncEnumeratorInternal(CancellationTo while (await remaining.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) #else while (remaining.ReadLine() is { } line) +#endif + { + yield return line; + } + } + + private async IAsyncEnumerator GetAsyncEnumeratorInternal2(CancellationToken cancellationToken) + { + List tasks = []; + do + { + if (await this.GetNextLineAsync(tasks, cancellationToken).ConfigureAwait(false) is { } nextLine) + { + yield return nextLine; + } + else + { + yield break; + } + } + while (tasks.Count != 1); // both readers not done + + // phase 2: finish reading the remaining stream + var remaining = tasks[0].Reader; +#if NET7_0_OR_GREATER + while (await remaining.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) +#else + while (remaining.ReadLine() is { } line) #endif { yield return line; From 750b1e0d2b78aa85d2cf7e2dad25cd71d27b4af1 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sat, 9 Mar 2024 20:35:48 -0500 Subject: [PATCH 11/28] Fix all bugs to pass all tests (but seeing some transient failures of LongRunningTaskSchedulerTest.FuzzTest) --- .../Streams/AsyncEnumerableAdapter.cs | 24 +++ .../Streams/MergedLinesEnumerableTestAsync.cs | 137 +------------ ...st.cs => MergedLinesEnumerableTestBase.cs} | 80 ++++---- .../Streams/MergedLinesEnumerableTestSync.cs | 11 ++ .../Streams/MergedLinesEnumerable.cs | 186 +++--------------- 5 files changed, 112 insertions(+), 326 deletions(-) create mode 100644 MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs rename MedallionShell.Tests/Streams/{MergedLinesEnumerableTest.cs => MergedLinesEnumerableTestBase.cs} (55%) create mode 100644 MedallionShell.Tests/Streams/MergedLinesEnumerableTestSync.cs diff --git a/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs b/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs new file mode 100644 index 0000000..36517a5 --- /dev/null +++ b/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Medallion.Shell.Tests.Streams; + +public class AsyncEnumerableAdapter(IEnumerable enumerable) : IAsyncEnumerable +{ + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => + new AsyncEnumeratorAdapter(enumerable.GetEnumerator()); + + private class AsyncEnumeratorAdapter(IEnumerator enumerator) : IAsyncEnumerator + { + public string Current => enumerator.Current; + + public ValueTask DisposeAsync() + { + enumerator.Dispose(); + return new(Task.CompletedTask); + } + + public ValueTask MoveNextAsync() => new(enumerator.MoveNext()); + } +} \ No newline at end of file diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs index 271041b..29345bd 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs @@ -1,136 +1,13 @@ -#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER -using System; +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER using System.Collections.Generic; using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using Medallion.Shell.Streams; -using Moq; -using NUnit.Framework; -namespace Medallion.Shell.Tests.Streams -{ - public class MergedLinesEnumerableTestAsync - { - [Test] - public async Task TestOneIsEmpty() - { - var empty1 = new StringReader(string.Empty); - var nonEmpty1 = new StringReader("abc\r\ndef\r\nghi\r\njkl"); - - var asyncEnumerable1 = new MergedLinesEnumerable(empty1, nonEmpty1); - var list1 = await asyncEnumerable1.ToListAsync(); - list1.SequenceEqual(["abc", "def", "ghi", "jkl"]) - .ShouldEqual(true, string.Join(", ", list1)); - - var empty2 = new StringReader(string.Empty); - var nonEmpty2 = new StringReader("a\nbb\nccc\n"); - var asyncEnumerable2 = new MergedLinesEnumerable(nonEmpty2, empty2); - var list2 = await asyncEnumerable2.ToListAsync(); - list2.SequenceEqual(["a", "bb", "ccc"]) - .ShouldEqual(true, string.Join(", ", list2)); - } - - [Test] - public async Task TestBothAreEmpty() - { - var list = await new MergedLinesEnumerable(new StringReader(string.Empty), new StringReader(string.Empty)).ToListAsync(); - list.Count.ShouldEqual(0, string.Join(", ", list)); - } - - [Test] - public async Task TestBothArePopulatedEqualSizes() - { - var list = await new MergedLinesEnumerable( - new StringReader("a\nbb\nccc"), - new StringReader("1\r\n22\r\n333") - ) - .ToListAsync(); - string.Join(", ", list).ShouldEqual("a, 1, bb, 22, ccc, 333"); - } - - [Test] - public async Task TestBothArePopulatedDifferenceSizes() - { - var lines1 = string.Join("\n", ["x", "y", "z"]); - var lines2 = string.Join("\n", ["1", "2", "3", "4", "5"]); - - var list1 = await new MergedLinesEnumerable(new StringReader(lines1), new StringReader(lines2)) - .ToListAsync(); - string.Join(", ", list1).ShouldEqual("x, 1, y, 2, z, 3, 4, 5"); - - var list2 = await new MergedLinesEnumerable(new StringReader(lines2), new StringReader(lines1)) - .ToListAsync(); - string.Join(", ", list2).ShouldEqual("1, x, 2, y, 3, z, 4, 5"); - } - - [Test] - public void TestConsumeTwice() - { - var asyncEnumerable = new MergedLinesEnumerable(new StringReader("a"), new StringReader("b")); - asyncEnumerable.GetAsyncEnumerator(); - Assert.Throws(() => asyncEnumerable.GetAsyncEnumerator()); - } - - [Test] - public void TestOneThrows() - { - void TestOneThrows(bool reverse) - { - var reader1 = new StringReader("a\nb\nc"); - var count = 0; - var mockReader = new Mock(MockBehavior.Strict); - mockReader.Setup(r => r.ReadLineAsync()) - .ReturnsAsync(() => ++count < 3 ? "LINE" : throw new TimeZoneNotFoundException()); +namespace Medallion.Shell.Tests.Streams; - Assert.ThrowsAsync( - async () => await new MergedLinesEnumerable( - reverse ? mockReader.Object : reader1, - reverse ? reader1 : mockReader.Object - ) - .ToListAsync() - ); - } - - TestOneThrows(reverse: false); - TestOneThrows(reverse: true); - } - - [Test] - public async Task FuzzTest() - { - var pipe1 = new Pipe(); - var pipe2 = new Pipe(); - - var asyncEnumerable = new MergedLinesEnumerable(new StreamReader(pipe1.OutputStream), new StreamReader(pipe2.OutputStream)); - - var strings1 = await AsyncEnumerable.Range(0, 2000).Select(_ => Guid.NewGuid().ToString()).ToArrayAsync(); - var strings2 = await AsyncEnumerable.Range(0, 2300).Select(_ => Guid.NewGuid().ToString()).ToArrayAsync(); - - static void WriteStrings(IReadOnlyList strings, Pipe pipe) - { - var spinWait = default(SpinWait); - var random = new Random(Guid.NewGuid().GetHashCode()); - using var writer = new StreamWriter(pipe.InputStream); - foreach (var line in strings) - { - if (random.Next(4) == 1) - { - spinWait.SpinOnce(); - } - - writer.WriteLine(line); - } - } - - var task1 = Task.Run(() => WriteStrings(strings1, pipe1)); - var task2 = Task.Run(() => WriteStrings(strings2, pipe2)); - var consumeTask = Task.Run(async () => await asyncEnumerable.ToListAsync()); - Task.WaitAll(task1, task2, consumeTask); - - CollectionAssert.AreEquivalent(strings1.Concat(strings2).ToList(), await consumeTask); - } - } +public class MergedLinesEnumerableTestAsync : MergedLinesEnumerableTestBase +{ + protected override IAsyncEnumerable Create(TextReader reader1, TextReader reader2) => + new MergedLinesEnumerable(reader1, reader2); } -#endif +#endif \ No newline at end of file diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTest.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs similarity index 55% rename from MedallionShell.Tests/Streams/MergedLinesEnumerableTest.cs rename to MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs index 0eda3c3..b74d5f7 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTest.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; using Medallion.Shell.Streams; @@ -11,66 +10,68 @@ namespace Medallion.Shell.Tests.Streams { - public class MergedLinesEnumerableTest + public abstract class MergedLinesEnumerableTestBase { + protected abstract IAsyncEnumerable Create(TextReader reader1, TextReader reader2); + [Test] - public void TestOneIsEmpty() + public async Task TestOneIsEmpty() { var empty1 = new StringReader(string.Empty); var nonEmpty1 = new StringReader("abc\r\ndef\r\nghi\r\njkl"); - var enumerable1 = new MergedLinesEnumerable(empty1, nonEmpty1); - var list1 = enumerable1.ToList(); - list1.SequenceEqual(new[] { "abc", "def", "ghi", "jkl" }) + var enumerable1 = this.Create(empty1, nonEmpty1); + var list1 = await enumerable1.ToListAsync(); + list1.SequenceEqual(["abc", "def", "ghi", "jkl"]) .ShouldEqual(true, string.Join(", ", list1)); var empty2 = new StringReader(string.Empty); var nonEmpty2 = new StringReader("a\nbb\nccc\n"); - var enumerable2 = new MergedLinesEnumerable(nonEmpty2, empty2); - var list2 = enumerable2.ToList(); - list2.SequenceEqual(new[] { "a", "bb", "ccc" }) + var enumerable2 = this.Create(nonEmpty2, empty2); + var list2 = await enumerable2.ToListAsync(); + list2.SequenceEqual(["a", "bb", "ccc"]) .ShouldEqual(true, string.Join(", ", list2)); } [Test] - public void TestBothAreEmpty() + public async Task TestBothAreEmpty() { - var list = new MergedLinesEnumerable(new StringReader(string.Empty), new StringReader(string.Empty)).ToList(); + var list = await this.Create(new StringReader(string.Empty), new StringReader(string.Empty)).ToListAsync(); list.Count.ShouldEqual(0, string.Join(", ", list)); } [Test] - public void TestBothArePopulatedEqualSizes() + public async Task TestBothArePopulatedEqualSizes() { - var list = new MergedLinesEnumerable( + var list = await this.Create( new StringReader("a\nbb\nccc"), new StringReader("1\r\n22\r\n333") ) - .ToList(); + .ToListAsync(); string.Join(", ", list).ShouldEqual("a, 1, bb, 22, ccc, 333"); } [Test] - public void TestBothArePopulatedDifferenceSizes() + public async Task TestBothArePopulatedDifferenceSizes() { - var lines1 = string.Join("\n", new[] { "x", "y", "z" }); - var lines2 = string.Join("\n", new[] { "1", "2", "3", "4", "5" }); + var lines1 = string.Join("\n", ["x", "y", "z"]); + var lines2 = string.Join("\n", ["1", "2", "3", "4", "5"]); - var list1 = new MergedLinesEnumerable(new StringReader(lines1), new StringReader(lines2)) - .ToList(); + var list1 = await this.Create(new StringReader(lines1), new StringReader(lines2)) + .ToListAsync(); string.Join(", ", list1).ShouldEqual("x, 1, y, 2, z, 3, 4, 5"); - var list2 = new MergedLinesEnumerable(new StringReader(lines2), new StringReader(lines1)) - .ToList(); + var list2 = await this.Create(new StringReader(lines2), new StringReader(lines1)) + .ToListAsync(); string.Join(", ", list2).ShouldEqual("1, x, 2, y, 3, z, 4, 5"); } [Test] public void TestConsumeTwice() { - var enumerable = new MergedLinesEnumerable(new StringReader("a"), new StringReader("b")); - enumerable.GetEnumerator(); - Assert.Throws(() => enumerable.GetEnumerator()); + var asyncEnumerable = this.Create(new StringReader("a"), new StringReader("b")); + asyncEnumerable.GetAsyncEnumerator(); + Assert.Throws(() => asyncEnumerable.GetAsyncEnumerator()); } [Test] @@ -84,12 +85,11 @@ void TestOneThrows(bool reverse) mockReader.Setup(r => r.ReadLineAsync()) .ReturnsAsync(() => ++count < 3 ? "LINE" : throw new TimeZoneNotFoundException()); - Assert.Throws( - () => new MergedLinesEnumerable( + Assert.ThrowsAsync( + async () => await this.Create( reverse ? mockReader.Object : reader1, reverse ? reader1 : mockReader.Object - ) - .ToList() + ).ToListAsync() ); } @@ -98,12 +98,12 @@ void TestOneThrows(bool reverse) } [Test] - public void FuzzTest() + public async Task FuzzTest() { var pipe1 = new Pipe(); var pipe2 = new Pipe(); - var enumerable = new MergedLinesEnumerable(new StreamReader(pipe1.OutputStream), new StreamReader(pipe2.OutputStream)); + var enumerable = this.Create(new StreamReader(pipe1.OutputStream), new StreamReader(pipe2.OutputStream)); var strings1 = Enumerable.Range(0, 2000).Select(_ => Guid.NewGuid().ToString()).ToArray(); var strings2 = Enumerable.Range(0, 2300).Select(_ => Guid.NewGuid().ToString()).ToArray(); @@ -112,26 +112,24 @@ void WriteStrings(IReadOnlyList strings, Pipe pipe) { var spinWait = default(SpinWait); var random = new Random(Guid.NewGuid().GetHashCode()); - using (var writer = new StreamWriter(pipe.InputStream)) + using var writer = new StreamWriter(pipe.InputStream); + foreach (var line in strings) { - foreach (var line in strings) + if (random.Next(10) == 1) { - if (random.Next(4) == 1) - { - spinWait.SpinOnce(); - } - - writer.WriteLine(line); + spinWait.SpinOnce(); } + + writer.WriteLine(line); } } var task1 = Task.Run(() => WriteStrings(strings1, pipe1)); var task2 = Task.Run(() => WriteStrings(strings2, pipe2)); - var consumeTask = Task.Run(() => enumerable.ToList()); - Task.WaitAll(task1, task2, consumeTask); + Task.WaitAll(task1, task2); - CollectionAssert.AreEquivalent(strings1.Concat(strings2).ToList(), consumeTask.Result); + var consumeTask = Task.Run(async () => await enumerable.ToListAsync()); + CollectionAssert.AreEquivalent(strings1.Concat(strings2).ToList(), await consumeTask); } } } diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestSync.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestSync.cs new file mode 100644 index 0000000..c1d24b5 --- /dev/null +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestSync.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using System.IO; +using Medallion.Shell.Streams; + +namespace Medallion.Shell.Tests.Streams; + +public class MergedLinesEnumerableTestSync : MergedLinesEnumerableTestBase +{ + protected override IAsyncEnumerable Create(TextReader reader1, TextReader reader2) => + new AsyncEnumerableAdapter(new MergedLinesEnumerable(reader1, reader2)); +} \ No newline at end of file diff --git a/MedallionShell/Streams/MergedLinesEnumerable.cs b/MedallionShell/Streams/MergedLinesEnumerable.cs index 181c8b2..52a2f66 100644 --- a/MedallionShell/Streams/MergedLinesEnumerable.cs +++ b/MedallionShell/Streams/MergedLinesEnumerable.cs @@ -25,15 +25,6 @@ public MergedLinesEnumerable(TextReader standardOutput, TextReader standardError this.standardError = standardError; } -#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER - public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) - { - this.AssertNoMultipleEnumeration(); - - return this.GetAsyncEnumeratorInternal(cancellationToken); - } -#endif - public IEnumerator GetEnumerator() { this.AssertNoMultipleEnumeration(); @@ -49,82 +40,17 @@ private void AssertNoMultipleEnumeration() => IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); - private IEnumerator GetEnumeratorInternal3() - { - var tasks = new List(capacity: 2); - tasks.Add(new ReaderAndTask(this.standardOutput)); - tasks.Add(new ReaderAndTask(this.standardError)); - - // phase 1: read both streams simultaneously, alternating between which is given priority. - // Stop when one (or both) streams is exhausted - - TextReader remaining; - while (true) - { - ReaderAndTask next; - if (tasks[0].Task.IsCompleted) - { - next = tasks[0]; - } - else if (tasks[1].Task.IsCompleted) - { - next = tasks[1]; - } - else - { - var nextCompleted = Task.WhenAny(tasks.Select(t => t.Task)).GetAwaiter().GetResult(); - next = tasks[0].Task == nextCompleted ? tasks[0] : tasks[1]; - } - - var nextLine = next.Task.GetAwaiter().GetResult(); - tasks.Remove(next); - - if (nextLine != null) - { - yield return nextLine; - tasks.Add(new ReaderAndTask(next.Reader)); - } - else - { - var otherAsyncLine = tasks[0].Task.GetAwaiter().GetResult(); - if (otherAsyncLine != null) - { - yield return otherAsyncLine; - remaining = tasks[0].Reader; - break; - } - else - { - yield break; - } - } - } - - // phase 2: finish reading the remaining stream - - string? line; - while ((line = remaining.ReadLine()) != null) - { - yield return line; - } - } - private IEnumerator GetEnumeratorInternal() { - List tasks = []; + List tasks = [new(this.standardOutput), new(this.standardError)]; // phase 1: read both streams simultaneously, alternating between which is given priority. // Stop when both streams are exhausted do { - if (this.GetNextLineAsync(tasks).GetAwaiter().GetResult() is { } nextLine) - { - yield return nextLine; - } - else - { - yield break; - } + var nextLine = this.GetNextLineOrDefaultAsync(tasks).GetAwaiter().GetResult(); + if (nextLine is null) { yield break; } // both readers done + yield return nextLine; } while (tasks.Count != 1); @@ -136,25 +62,37 @@ private IEnumerator GetEnumeratorInternal() } } - - private async Task GetNextLineAsync(List tasks, CancellationToken cancellationToken = default) + private async Task GetNextLineOrDefaultAsync(List tasks, CancellationToken cancellationToken = default) { Debug.Assert(tasks.Count is 0 or 2, "There should be EITHER nothing OR both stdout and stderr."); if (tasks.Count == 0) { - tasks = [new(this.standardOutput), new(this.standardError)]; + tasks = [new(this.standardOutput, cancellationToken), new(this.standardError, cancellationToken)]; } - + // Figure out which of the 2 tasks is completed. Remove that task and, if the result is not null, replace it // by queueing up the next read. // If the result is not null, return the result. If the result is null instead await the other task and return its result. - var nextCompleted = await Task.WhenAny(tasks.Select(t => t.Task)).ConfigureAwait(false); - var next = tasks[0].Task == nextCompleted ? tasks[0] : tasks[1]; + ReaderAndTask next; + if (tasks[0].Task.IsCompleted) + { + next = tasks[0]; + } + else if (tasks[1].Task.IsCompleted) + { + next = tasks[1]; + } + else + { + var nextCompleted = await Task.WhenAny(tasks.Select(t => t.Task)).ConfigureAwait(false); + next = tasks[0].Task == nextCompleted ? tasks[0] : tasks[1]; + } var nextLine = await next.Task.ConfigureAwait(false); tasks.Remove(next); + if (nextLine is { }) { tasks.Add(new ReaderAndTask(next.Reader, cancellationToken)); @@ -171,83 +109,21 @@ private IEnumerator GetEnumeratorInternal() } #if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER - private async IAsyncEnumerator GetAsyncEnumeratorInternal(CancellationToken cancellationToken) + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { - var tasks = new List(capacity: 2) - { - new(this.standardOutput), - new(this.standardError), - }; - - // phase 1: read both streams simultaneously, alternating between which is given priority. - // Stop when one (or both) streams is exhausted - - TextReader remaining; - while (true) - { - ReaderAndTask next; - if (tasks[0].Task.IsCompleted) - { - next = tasks[0]; - } - else if (tasks[1].Task.IsCompleted) - { - next = tasks[1]; - } - else - { - var nextCompleted = await Task.WhenAny(tasks.Select(t => t.Task)).ConfigureAwait(false); - next = tasks[0].Task == nextCompleted ? tasks[0] : tasks[1]; - } - - var nextLine = await next.Task.ConfigureAwait(false); - tasks.Remove(next); - - if (nextLine != null) - { - yield return nextLine; - tasks.Add(new ReaderAndTask(next.Reader)); - } - else - { - var otherAsyncLine = await tasks[0].Task.ConfigureAwait(false); - if (otherAsyncLine != null) - { - yield return otherAsyncLine; - remaining = tasks[0].Reader; - break; - } - else - { - yield break; - } - } - } + this.AssertNoMultipleEnumeration(); - // phase 2: finish reading the remaining stream -#if NET7_0_OR_GREATER - while (await remaining.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) -#else - while (remaining.ReadLine() is { } line) -#endif - { - yield return line; - } + return this.GetAsyncEnumeratorInternal(cancellationToken); } - - private async IAsyncEnumerator GetAsyncEnumeratorInternal2(CancellationToken cancellationToken) + + private async IAsyncEnumerator GetAsyncEnumeratorInternal(CancellationToken cancellationToken) { - List tasks = []; + var tasks = new List(capacity: 2) { new(this.standardOutput, cancellationToken), new(this.standardError, cancellationToken) }; do { - if (await this.GetNextLineAsync(tasks, cancellationToken).ConfigureAwait(false) is { } nextLine) - { - yield return nextLine; - } - else - { - yield break; - } + var nextLine = await this.GetNextLineOrDefaultAsync(tasks, cancellationToken).ConfigureAwait(false); + if (nextLine is null) { yield break; } // both readers done + yield return nextLine; } while (tasks.Count != 1); // both readers not done From caa942822880cdaecafde09364e3d8becd88fba9 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sat, 9 Mar 2024 20:46:03 -0500 Subject: [PATCH 12/28] Clean up per Visual Studio's suggestions and remove comments --- .../Streams/MergedLinesEnumerableTestBase.cs | 4 +- .../Streams/MergedLinesEnumerable.cs | 37 +++++-------------- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs index b74d5f7..78d20d4 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs @@ -108,14 +108,14 @@ public async Task FuzzTest() var strings1 = Enumerable.Range(0, 2000).Select(_ => Guid.NewGuid().ToString()).ToArray(); var strings2 = Enumerable.Range(0, 2300).Select(_ => Guid.NewGuid().ToString()).ToArray(); - void WriteStrings(IReadOnlyList strings, Pipe pipe) + static void WriteStrings(IReadOnlyList strings, Pipe pipe) { var spinWait = default(SpinWait); var random = new Random(Guid.NewGuid().GetHashCode()); using var writer = new StreamWriter(pipe.InputStream); foreach (var line in strings) { - if (random.Next(10) == 1) + if (random.Next(5) == 1) { spinWait.SpinOnce(); } diff --git a/MedallionShell/Streams/MergedLinesEnumerable.cs b/MedallionShell/Streams/MergedLinesEnumerable.cs index 52a2f66..047df90 100644 --- a/MedallionShell/Streams/MergedLinesEnumerable.cs +++ b/MedallionShell/Streams/MergedLinesEnumerable.cs @@ -9,22 +9,15 @@ namespace Medallion.Shell.Streams { - internal sealed class MergedLinesEnumerable : + internal sealed class MergedLinesEnumerable(TextReader standardOutput, TextReader standardError) : #if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER IEnumerable, IAsyncEnumerable #else IEnumerable #endif { - private readonly TextReader standardOutput, standardError; private int consumed; - public MergedLinesEnumerable(TextReader standardOutput, TextReader standardError) - { - this.standardOutput = standardOutput; - this.standardError = standardError; - } - public IEnumerator GetEnumerator() { this.AssertNoMultipleEnumeration(); @@ -42,19 +35,16 @@ private void AssertNoMultipleEnumeration() => private IEnumerator GetEnumeratorInternal() { - List tasks = [new(this.standardOutput), new(this.standardError)]; + List tasks = [new(standardOutput), new(standardError)]; - // phase 1: read both streams simultaneously, alternating between which is given priority. - // Stop when both streams are exhausted do { var nextLine = this.GetNextLineOrDefaultAsync(tasks).GetAwaiter().GetResult(); - if (nextLine is null) { yield break; } // both readers done + if (nextLine is null) { yield break; } yield return nextLine; } while (tasks.Count != 1); - // phase 2: finish reading the remaining stream var remaining = tasks[0].Reader; while (remaining.ReadLine() is { } line) { @@ -68,13 +58,9 @@ private IEnumerator GetEnumeratorInternal() if (tasks.Count == 0) { - tasks = [new(this.standardOutput, cancellationToken), new(this.standardError, cancellationToken)]; + tasks = [new(standardOutput, cancellationToken), new(standardError, cancellationToken)]; } - // Figure out which of the 2 tasks is completed. Remove that task and, if the result is not null, replace it - // by queueing up the next read. - // If the result is not null, return the result. If the result is null instead await the other task and return its result. - ReaderAndTask next; if (tasks[0].Task.IsCompleted) { @@ -102,10 +88,7 @@ private IEnumerator GetEnumeratorInternal() { return otherAsyncLine; } - else - { - return null; - } + return null; } #if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER @@ -118,16 +101,16 @@ public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellatio private async IAsyncEnumerator GetAsyncEnumeratorInternal(CancellationToken cancellationToken) { - var tasks = new List(capacity: 2) { new(this.standardOutput, cancellationToken), new(this.standardError, cancellationToken) }; + List tasks = [new(standardOutput, cancellationToken), new(standardError, cancellationToken)]; + do { var nextLine = await this.GetNextLineOrDefaultAsync(tasks, cancellationToken).ConfigureAwait(false); - if (nextLine is null) { yield break; } // both readers done + if (nextLine is null) { yield break; } yield return nextLine; } - while (tasks.Count != 1); // both readers not done + while (tasks.Count != 1); - // phase 2: finish reading the remaining stream var remaining = tasks[0].Reader; #if NET7_0_OR_GREATER while (await remaining.ReadLineAsync(cancellationToken).ConfigureAwait(false) is { } line) @@ -140,7 +123,7 @@ private async IAsyncEnumerator GetAsyncEnumeratorInternal(CancellationTo } #endif - private struct ReaderAndTask : IEquatable + private readonly struct ReaderAndTask : IEquatable { public ReaderAndTask(TextReader reader, CancellationToken cancellationToken = default) { From 3b99204872cccd745969b07a0280885228a8d928 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sat, 9 Mar 2024 21:03:28 -0500 Subject: [PATCH 13/28] Remove System.Linq.Async and use an extension method instead --- MedallionShell.Tests/MedallionShell.Tests.csproj | 1 - .../Streams/MergedLinesEnumerableTestBase.cs | 12 ++++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/MedallionShell.Tests/MedallionShell.Tests.csproj b/MedallionShell.Tests/MedallionShell.Tests.csproj index df97030..43b8a45 100644 --- a/MedallionShell.Tests/MedallionShell.Tests.csproj +++ b/MedallionShell.Tests/MedallionShell.Tests.csproj @@ -19,7 +19,6 @@ - All diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs index 78d20d4..6b00749 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs @@ -132,4 +132,16 @@ static void WriteStrings(IReadOnlyList strings, Pipe pipe) CollectionAssert.AreEquivalent(strings1.Concat(strings2).ToList(), await consumeTask); } } + public static class AsyncEnumerableExtensions + { + public static async Task> ToListAsync(this IAsyncEnumerable strings) + { + List list = []; + await foreach (var item in strings) + { + list.Add(item); + } + return list; + } + } } From 6a2baae3a276f2b77990f4cad6c9b1a95b62f318 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sat, 9 Mar 2024 21:03:50 -0500 Subject: [PATCH 14/28] Fix Condition for Microsoft.Bcl.AsyncInterfaces --- MedallionShell/MedallionShell.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MedallionShell/MedallionShell.csproj b/MedallionShell/MedallionShell.csproj index b1b33c6..f7abe77 100644 --- a/MedallionShell/MedallionShell.csproj +++ b/MedallionShell/MedallionShell.csproj @@ -80,7 +80,7 @@ MedallionShell.ProcessSignaler.exe - + \ No newline at end of file From 983a28f7b00faba0d43dca182e9d449ee9f8fa54 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sat, 9 Mar 2024 21:15:25 -0500 Subject: [PATCH 15/28] Revert primary constructor changes to fix CI failures --- MedallionShell/Streams/MergedLinesEnumerable.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/MedallionShell/Streams/MergedLinesEnumerable.cs b/MedallionShell/Streams/MergedLinesEnumerable.cs index 047df90..dfe8b73 100644 --- a/MedallionShell/Streams/MergedLinesEnumerable.cs +++ b/MedallionShell/Streams/MergedLinesEnumerable.cs @@ -9,15 +9,22 @@ namespace Medallion.Shell.Streams { - internal sealed class MergedLinesEnumerable(TextReader standardOutput, TextReader standardError) : + internal sealed class MergedLinesEnumerable : #if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER IEnumerable, IAsyncEnumerable #else IEnumerable #endif { + private readonly TextReader standardOutput, standardError; private int consumed; + public MergedLinesEnumerable(TextReader standardOutput, TextReader standardError) + { + this.standardOutput = standardOutput; + this.standardError = standardError; + } + public IEnumerator GetEnumerator() { this.AssertNoMultipleEnumeration(); @@ -35,7 +42,7 @@ private void AssertNoMultipleEnumeration() => private IEnumerator GetEnumeratorInternal() { - List tasks = [new(standardOutput), new(standardError)]; + List tasks = [new(this.standardOutput), new(this.standardError)]; do { @@ -58,7 +65,7 @@ private IEnumerator GetEnumeratorInternal() if (tasks.Count == 0) { - tasks = [new(standardOutput, cancellationToken), new(standardError, cancellationToken)]; + tasks = [new(this.standardOutput, cancellationToken), new(this.standardError, cancellationToken)]; } ReaderAndTask next; @@ -101,7 +108,7 @@ public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellatio private async IAsyncEnumerator GetAsyncEnumeratorInternal(CancellationToken cancellationToken) { - List tasks = [new(standardOutput, cancellationToken), new(standardError, cancellationToken)]; + List tasks = [new(this.standardOutput, cancellationToken), new(this.standardError, cancellationToken)]; do { From 7104ceaf4748cf64c649e655f4d03066d5a05482 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sat, 9 Mar 2024 21:26:36 -0500 Subject: [PATCH 16/28] Also revert collection expressions changes --- .../Streams/MergedLinesEnumerableTestBase.cs | 6 +++--- MedallionShell/Streams/MergedLinesEnumerable.cs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs index 6b00749..8387a73 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs @@ -54,8 +54,8 @@ public async Task TestBothArePopulatedEqualSizes() [Test] public async Task TestBothArePopulatedDifferenceSizes() { - var lines1 = string.Join("\n", ["x", "y", "z"]); - var lines2 = string.Join("\n", ["1", "2", "3", "4", "5"]); + var lines1 = string.Join("\n", new[] { "x", "y", "z" }); + var lines2 = string.Join("\n", new[] { "1", "2", "3", "4", "5" }); var list1 = await this.Create(new StringReader(lines1), new StringReader(lines2)) .ToListAsync(); @@ -136,7 +136,7 @@ public static class AsyncEnumerableExtensions { public static async Task> ToListAsync(this IAsyncEnumerable strings) { - List list = []; + List list = new(); await foreach (var item in strings) { list.Add(item); diff --git a/MedallionShell/Streams/MergedLinesEnumerable.cs b/MedallionShell/Streams/MergedLinesEnumerable.cs index dfe8b73..1b33b48 100644 --- a/MedallionShell/Streams/MergedLinesEnumerable.cs +++ b/MedallionShell/Streams/MergedLinesEnumerable.cs @@ -42,7 +42,7 @@ private void AssertNoMultipleEnumeration() => private IEnumerator GetEnumeratorInternal() { - List tasks = [new(this.standardOutput), new(this.standardError)]; + List tasks = new(capacity: 2) { new(this.standardOutput), new(this.standardError) }; do { @@ -65,7 +65,7 @@ private IEnumerator GetEnumeratorInternal() if (tasks.Count == 0) { - tasks = [new(this.standardOutput, cancellationToken), new(this.standardError, cancellationToken)]; + tasks = new(capacity: 2) { new(this.standardOutput, cancellationToken), new(this.standardError, cancellationToken) }; } ReaderAndTask next; @@ -108,7 +108,7 @@ public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellatio private async IAsyncEnumerator GetAsyncEnumeratorInternal(CancellationToken cancellationToken) { - List tasks = [new(this.standardOutput, cancellationToken), new(this.standardError, cancellationToken)]; + List tasks = new(capacity: 2) { new(this.standardOutput, cancellationToken), new(this.standardError, cancellationToken) }; do { From f6a555a46ae432121028311cf0b3e0c8f81a101c Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sat, 9 Mar 2024 22:59:37 -0500 Subject: [PATCH 17/28] Revert primary constructor in AsyncEnumerableAdapter --- .../Streams/AsyncEnumerableAdapter.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs b/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs index 36517a5..1a81e7d 100644 --- a/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs +++ b/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs @@ -4,10 +4,17 @@ namespace Medallion.Shell.Tests.Streams; -public class AsyncEnumerableAdapter(IEnumerable enumerable) : IAsyncEnumerable +public class AsyncEnumerableAdapter : IAsyncEnumerable { + private readonly IEnumerable strings; + + public AsyncEnumerableAdapter(IEnumerable strings) + { + this.strings = strings; + } + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => - new AsyncEnumeratorAdapter(enumerable.GetEnumerator()); + new AsyncEnumeratorAdapter(this.strings.GetEnumerator()); private class AsyncEnumeratorAdapter(IEnumerator enumerator) : IAsyncEnumerator { From 903087449e405363cda8d335aa270d573f581e80 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sat, 9 Mar 2024 23:10:35 -0500 Subject: [PATCH 18/28] Revert primary constructor in AsyncEnumeratorAdapter --- .../Streams/AsyncEnumerableAdapter.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs b/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs index 1a81e7d..1392e4f 100644 --- a/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs +++ b/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs @@ -16,16 +16,23 @@ public AsyncEnumerableAdapter(IEnumerable strings) public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => new AsyncEnumeratorAdapter(this.strings.GetEnumerator()); - private class AsyncEnumeratorAdapter(IEnumerator enumerator) : IAsyncEnumerator + private class AsyncEnumeratorAdapter : IAsyncEnumerator { - public string Current => enumerator.Current; + private readonly IEnumerator enumerator; + + public AsyncEnumeratorAdapter(IEnumerator enumerator) + { + this.enumerator = enumerator; + } + + public string Current => this.enumerator.Current; public ValueTask DisposeAsync() { - enumerator.Dispose(); + this.enumerator.Dispose(); return new(Task.CompletedTask); } - public ValueTask MoveNextAsync() => new(enumerator.MoveNext()); + public ValueTask MoveNextAsync() => new(this.enumerator.MoveNext()); } } \ No newline at end of file From b2b7bbe1b05cd168716e7821d9e3eb70979203f9 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sat, 9 Mar 2024 23:20:50 -0500 Subject: [PATCH 19/28] Revert collection expression in MergedLinesEnumerableTestBase --- MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs index 8387a73..17c5cf7 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs @@ -22,14 +22,14 @@ public async Task TestOneIsEmpty() var enumerable1 = this.Create(empty1, nonEmpty1); var list1 = await enumerable1.ToListAsync(); - list1.SequenceEqual(["abc", "def", "ghi", "jkl"]) + list1.SequenceEqual(new[] { "abc", "def", "ghi", "jkl" }) .ShouldEqual(true, string.Join(", ", list1)); var empty2 = new StringReader(string.Empty); var nonEmpty2 = new StringReader("a\nbb\nccc\n"); var enumerable2 = this.Create(nonEmpty2, empty2); var list2 = await enumerable2.ToListAsync(); - list2.SequenceEqual(["a", "bb", "ccc"]) + list2.SequenceEqual(new[] { "a", "bb", "ccc" }) .ShouldEqual(true, string.Join(", ", list2)); } From f9f0813228c201913989d171e8b89dc569c28ef6 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sun, 10 Mar 2024 21:20:55 -0400 Subject: [PATCH 20/28] Fix spacing and variable names --- .../Streams/MergedLinesEnumerableTestBase.cs | 10 ++++------ MedallionShell/MedallionShell.csproj | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs index 17c5cf7..2084c3f 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs @@ -132,16 +132,14 @@ static void WriteStrings(IReadOnlyList strings, Pipe pipe) CollectionAssert.AreEquivalent(strings1.Concat(strings2).ToList(), await consumeTask); } } + public static class AsyncEnumerableExtensions { public static async Task> ToListAsync(this IAsyncEnumerable strings) { - List list = new(); - await foreach (var item in strings) - { - list.Add(item); - } - return list; + List result = new(); + await foreach (var item in strings) { result.Add(item); } + return result; } } } diff --git a/MedallionShell/MedallionShell.csproj b/MedallionShell/MedallionShell.csproj index f7abe77..8d6498b 100644 --- a/MedallionShell/MedallionShell.csproj +++ b/MedallionShell/MedallionShell.csproj @@ -45,7 +45,7 @@ - + From 8d18b9ddecf48e8f81e5759af6c8dd5a4c5abd6d Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sun, 10 Mar 2024 21:21:31 -0400 Subject: [PATCH 21/28] Fix preprocessor directives for IAsyncEnumerable/IAsyncEnumerator --- .../Streams/MergedLinesEnumerableTestAsync.cs | 2 +- MedallionShell/Streams/MergedLinesEnumerable.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs index 29345bd..d0d9b55 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs @@ -1,4 +1,4 @@ -#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_0_OR_GREATER using System.Collections.Generic; using System.IO; using Medallion.Shell.Streams; diff --git a/MedallionShell/Streams/MergedLinesEnumerable.cs b/MedallionShell/Streams/MergedLinesEnumerable.cs index 1b33b48..655f918 100644 --- a/MedallionShell/Streams/MergedLinesEnumerable.cs +++ b/MedallionShell/Streams/MergedLinesEnumerable.cs @@ -10,7 +10,7 @@ namespace Medallion.Shell.Streams { internal sealed class MergedLinesEnumerable : -#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_0_OR_GREATER IEnumerable, IAsyncEnumerable #else IEnumerable @@ -98,7 +98,7 @@ private IEnumerator GetEnumeratorInternal() return null; } -#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER +#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_0_OR_GREATER public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) { this.AssertNoMultipleEnumeration(); From 5151a25835831ccc7d476c32212552e5ab5b8cfc Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sun, 10 Mar 2024 21:32:54 -0400 Subject: [PATCH 22/28] Replace AsyncEnumerableAdapter with AsAsyncEnumerable --- .../Streams/AsyncEnumerableAdapter.cs | 38 ------------------- .../Streams/EnumerableExtensions.cs | 13 +++++++ .../Streams/MergedLinesEnumerableTestSync.cs | 2 +- 3 files changed, 14 insertions(+), 39 deletions(-) delete mode 100644 MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs create mode 100644 MedallionShell.Tests/Streams/EnumerableExtensions.cs diff --git a/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs b/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs deleted file mode 100644 index 1392e4f..0000000 --- a/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace Medallion.Shell.Tests.Streams; - -public class AsyncEnumerableAdapter : IAsyncEnumerable -{ - private readonly IEnumerable strings; - - public AsyncEnumerableAdapter(IEnumerable strings) - { - this.strings = strings; - } - - public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => - new AsyncEnumeratorAdapter(this.strings.GetEnumerator()); - - private class AsyncEnumeratorAdapter : IAsyncEnumerator - { - private readonly IEnumerator enumerator; - - public AsyncEnumeratorAdapter(IEnumerator enumerator) - { - this.enumerator = enumerator; - } - - public string Current => this.enumerator.Current; - - public ValueTask DisposeAsync() - { - this.enumerator.Dispose(); - return new(Task.CompletedTask); - } - - public ValueTask MoveNextAsync() => new(this.enumerator.MoveNext()); - } -} \ No newline at end of file diff --git a/MedallionShell.Tests/Streams/EnumerableExtensions.cs b/MedallionShell.Tests/Streams/EnumerableExtensions.cs new file mode 100644 index 0000000..5738664 --- /dev/null +++ b/MedallionShell.Tests/Streams/EnumerableExtensions.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace Medallion.Shell.Tests.Streams; + +internal static class EnumerableExtensions +{ + public static async IAsyncEnumerable AsAsyncEnumerable(this IEnumerable items) + { + await Task.CompletedTask; // make compiler happy + foreach (var item in items) { yield return item; } + } +} \ No newline at end of file diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestSync.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestSync.cs index c1d24b5..79f0ba3 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestSync.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestSync.cs @@ -7,5 +7,5 @@ namespace Medallion.Shell.Tests.Streams; public class MergedLinesEnumerableTestSync : MergedLinesEnumerableTestBase { protected override IAsyncEnumerable Create(TextReader reader1, TextReader reader2) => - new AsyncEnumerableAdapter(new MergedLinesEnumerable(reader1, reader2)); + new MergedLinesEnumerable(reader1, reader2).AsAsyncEnumerable(); } \ No newline at end of file From 97198a39d19349fb4ca104b497f213bbf2a1c910 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sun, 10 Mar 2024 23:19:41 -0400 Subject: [PATCH 23/28] Revert to doing Task.WaitAll(task1, task2, consumeTask) --- .../Streams/MergedLinesEnumerableTestBase.cs | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs index 2084c3f..cd92e45 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs @@ -97,25 +97,25 @@ void TestOneThrows(bool reverse) TestOneThrows(reverse: true); } + [Timeout(10000)] // something's wrong if it's taking more than 15 seconds [Test] - public async Task FuzzTest() + public void FuzzTest() { var pipe1 = new Pipe(); var pipe2 = new Pipe(); - var enumerable = this.Create(new StreamReader(pipe1.OutputStream), new StreamReader(pipe2.OutputStream)); + var asyncEnumerable = this.Create(new StreamReader(pipe1.OutputStream), new StreamReader(pipe2.OutputStream)); var strings1 = Enumerable.Range(0, 2000).Select(_ => Guid.NewGuid().ToString()).ToArray(); var strings2 = Enumerable.Range(0, 2300).Select(_ => Guid.NewGuid().ToString()).ToArray(); - static void WriteStrings(IReadOnlyList strings, Pipe pipe) + static void WriteStrings(IReadOnlyList strings, TextWriter writer) { var spinWait = default(SpinWait); var random = new Random(Guid.NewGuid().GetHashCode()); - using var writer = new StreamWriter(pipe.InputStream); foreach (var line in strings) { - if (random.Next(5) == 1) + if (random.Next(110) == 1) { spinWait.SpinOnce(); } @@ -123,13 +123,21 @@ static void WriteStrings(IReadOnlyList strings, Pipe pipe) writer.WriteLine(line); } } + + var task1 = Task.Run(() => + { + using StreamWriter writer1 = new(pipe1.InputStream); + WriteStrings(strings1, writer1); + }); + var task2 = Task.Run(() => + { + using StreamWriter writer2 = new(pipe2.InputStream); + WriteStrings(strings2, writer2); + }); + var consumeTask = asyncEnumerable.ToListAsync(); + Task.WaitAll(task1, task2, consumeTask); - var task1 = Task.Run(() => WriteStrings(strings1, pipe1)); - var task2 = Task.Run(() => WriteStrings(strings2, pipe2)); - Task.WaitAll(task1, task2); - - var consumeTask = Task.Run(async () => await enumerable.ToListAsync()); - CollectionAssert.AreEquivalent(strings1.Concat(strings2).ToList(), await consumeTask); + CollectionAssert.AreEquivalent(strings1.Concat(strings2).ToList(), consumeTask.Result); } } From 1420db9de53f142d793bdc7bbc168d57933a3453 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sun, 10 Mar 2024 23:38:28 -0400 Subject: [PATCH 24/28] Revert AsAsyncEnumerable changes to disallow repeated consumptions --- .../Streams/AsyncEnumerableAdapter.cs | 39 +++++++++++++++++++ .../Streams/EnumerableExtensions.cs | 13 ------- .../Streams/MergedLinesEnumerableTestSync.cs | 2 +- 3 files changed, 40 insertions(+), 14 deletions(-) create mode 100644 MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs delete mode 100644 MedallionShell.Tests/Streams/EnumerableExtensions.cs diff --git a/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs b/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs new file mode 100644 index 0000000..39b176e --- /dev/null +++ b/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Medallion.Shell.Tests.Streams; + +public class AsyncEnumerableAdapter : IAsyncEnumerable +{ + private readonly IEnumerable strings; + + public AsyncEnumerableAdapter(IEnumerable strings) + { + this.strings = strings; + } + + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) => + // this does not allow consuming the same IEnumerable twice + new AsyncEnumeratorAdapter(this.strings.GetEnumerator()); + + private class AsyncEnumeratorAdapter : IAsyncEnumerator + { + private readonly IEnumerator enumerator; + + public AsyncEnumeratorAdapter(IEnumerator enumerator) + { + this.enumerator = enumerator; + } + + public string Current => this.enumerator.Current; + + public ValueTask DisposeAsync() + { + this.enumerator.Dispose(); + return new(Task.CompletedTask); + } + + public ValueTask MoveNextAsync() => new(this.enumerator.MoveNext()); + } +} diff --git a/MedallionShell.Tests/Streams/EnumerableExtensions.cs b/MedallionShell.Tests/Streams/EnumerableExtensions.cs deleted file mode 100644 index 5738664..0000000 --- a/MedallionShell.Tests/Streams/EnumerableExtensions.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; - -namespace Medallion.Shell.Tests.Streams; - -internal static class EnumerableExtensions -{ - public static async IAsyncEnumerable AsAsyncEnumerable(this IEnumerable items) - { - await Task.CompletedTask; // make compiler happy - foreach (var item in items) { yield return item; } - } -} \ No newline at end of file diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestSync.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestSync.cs index 79f0ba3..c1d24b5 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestSync.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestSync.cs @@ -7,5 +7,5 @@ namespace Medallion.Shell.Tests.Streams; public class MergedLinesEnumerableTestSync : MergedLinesEnumerableTestBase { protected override IAsyncEnumerable Create(TextReader reader1, TextReader reader2) => - new MergedLinesEnumerable(reader1, reader2).AsAsyncEnumerable(); + new AsyncEnumerableAdapter(new MergedLinesEnumerable(reader1, reader2)); } \ No newline at end of file From 4c688360a5c606b3aedd4724857178fdb4284bc3 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sat, 29 Jun 2024 13:41:48 +0900 Subject: [PATCH 25/28] Minor style changes --- .../Streams/AsyncEnumerableAdapter.cs | 2 +- .../Streams/MergedLinesEnumerableTestBase.cs | 232 +++++++++--------- stylecop.analyzers.ruleset | 1 + 3 files changed, 117 insertions(+), 118 deletions(-) diff --git a/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs b/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs index 39b176e..2721441 100644 --- a/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs +++ b/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs @@ -31,7 +31,7 @@ public AsyncEnumeratorAdapter(IEnumerator enumerator) public ValueTask DisposeAsync() { this.enumerator.Dispose(); - return new(Task.CompletedTask); + return default; } public ValueTask MoveNextAsync() => new(this.enumerator.MoveNext()); diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs index cd92e45..bea9070 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs @@ -8,146 +8,144 @@ using Moq; using NUnit.Framework; -namespace Medallion.Shell.Tests.Streams +namespace Medallion.Shell.Tests.Streams; + +public abstract class MergedLinesEnumerableTestBase { - public abstract class MergedLinesEnumerableTestBase + protected abstract IAsyncEnumerable Create(TextReader reader1, TextReader reader2); + + [Test] + public async Task TestOneIsEmpty() { - protected abstract IAsyncEnumerable Create(TextReader reader1, TextReader reader2); + var empty1 = new StringReader(string.Empty); + var nonEmpty1 = new StringReader("abc\r\ndef\r\nghi\r\njkl"); + + var enumerable1 = this.Create(empty1, nonEmpty1); + var list1 = await enumerable1.ToListAsync(); + list1.SequenceEqual(new[] { "abc", "def", "ghi", "jkl" }) + .ShouldEqual(true, string.Join(", ", list1)); + + var empty2 = new StringReader(string.Empty); + var nonEmpty2 = new StringReader("a\nbb\nccc\n"); + var enumerable2 = this.Create(nonEmpty2, empty2); + var list2 = await enumerable2.ToListAsync(); + list2.SequenceEqual(new[] { "a", "bb", "ccc" }) + .ShouldEqual(true, string.Join(", ", list2)); + } - [Test] - public async Task TestOneIsEmpty() - { - var empty1 = new StringReader(string.Empty); - var nonEmpty1 = new StringReader("abc\r\ndef\r\nghi\r\njkl"); - - var enumerable1 = this.Create(empty1, nonEmpty1); - var list1 = await enumerable1.ToListAsync(); - list1.SequenceEqual(new[] { "abc", "def", "ghi", "jkl" }) - .ShouldEqual(true, string.Join(", ", list1)); - - var empty2 = new StringReader(string.Empty); - var nonEmpty2 = new StringReader("a\nbb\nccc\n"); - var enumerable2 = this.Create(nonEmpty2, empty2); - var list2 = await enumerable2.ToListAsync(); - list2.SequenceEqual(new[] { "a", "bb", "ccc" }) - .ShouldEqual(true, string.Join(", ", list2)); - } + [Test] + public async Task TestBothAreEmpty() + { + var list = await this.Create(new StringReader(string.Empty), new StringReader(string.Empty)).ToListAsync(); + list.Count.ShouldEqual(0, string.Join(", ", list)); + } - [Test] - public async Task TestBothAreEmpty() - { - var list = await this.Create(new StringReader(string.Empty), new StringReader(string.Empty)).ToListAsync(); - list.Count.ShouldEqual(0, string.Join(", ", list)); - } + [Test] + public async Task TestBothArePopulatedEqualSizes() + { + var list = await this.Create( + new StringReader("a\nbb\nccc"), + new StringReader("1\r\n22\r\n333") + ) + .ToListAsync(); + string.Join(", ", list).ShouldEqual("a, 1, bb, 22, ccc, 333"); + } - [Test] - public async Task TestBothArePopulatedEqualSizes() - { - var list = await this.Create( - new StringReader("a\nbb\nccc"), - new StringReader("1\r\n22\r\n333") - ) - .ToListAsync(); - string.Join(", ", list).ShouldEqual("a, 1, bb, 22, ccc, 333"); - } + [Test] + public async Task TestBothArePopulatedDifferenceSizes() + { + var lines1 = string.Join("\n", new[] { "x", "y", "z" }); + var lines2 = string.Join("\n", new[] { "1", "2", "3", "4", "5" }); - [Test] - public async Task TestBothArePopulatedDifferenceSizes() - { - var lines1 = string.Join("\n", new[] { "x", "y", "z" }); - var lines2 = string.Join("\n", new[] { "1", "2", "3", "4", "5" }); + var list1 = await this.Create(new StringReader(lines1), new StringReader(lines2)) + .ToListAsync(); + string.Join(", ", list1).ShouldEqual("x, 1, y, 2, z, 3, 4, 5"); - var list1 = await this.Create(new StringReader(lines1), new StringReader(lines2)) - .ToListAsync(); - string.Join(", ", list1).ShouldEqual("x, 1, y, 2, z, 3, 4, 5"); + var list2 = await this.Create(new StringReader(lines2), new StringReader(lines1)) + .ToListAsync(); + string.Join(", ", list2).ShouldEqual("1, x, 2, y, 3, z, 4, 5"); + } - var list2 = await this.Create(new StringReader(lines2), new StringReader(lines1)) - .ToListAsync(); - string.Join(", ", list2).ShouldEqual("1, x, 2, y, 3, z, 4, 5"); - } + [Test] + public void TestConsumeTwice() + { + var asyncEnumerable = this.Create(new StringReader("a"), new StringReader("b")); + asyncEnumerable.GetAsyncEnumerator(); + Assert.Throws(() => asyncEnumerable.GetAsyncEnumerator()); + } - [Test] - public void TestConsumeTwice() + [Test] + public void TestOneThrows() + { + void TestOneThrows(bool reverse) { - var asyncEnumerable = this.Create(new StringReader("a"), new StringReader("b")); - asyncEnumerable.GetAsyncEnumerator(); - Assert.Throws(() => asyncEnumerable.GetAsyncEnumerator()); + var reader1 = new StringReader("a\nb\nc"); + var count = 0; + var mockReader = new Mock(MockBehavior.Strict); + mockReader.Setup(r => r.ReadLineAsync()) + .ReturnsAsync(() => ++count < 3 ? "LINE" : throw new TimeZoneNotFoundException()); + + Assert.ThrowsAsync( + async () => await this.Create( + reverse ? mockReader.Object : reader1, + reverse ? reader1 : mockReader.Object + ).ToListAsync() + ); } - [Test] - public void TestOneThrows() - { - void TestOneThrows(bool reverse) - { - var reader1 = new StringReader("a\nb\nc"); - var count = 0; - var mockReader = new Mock(MockBehavior.Strict); - mockReader.Setup(r => r.ReadLineAsync()) - .ReturnsAsync(() => ++count < 3 ? "LINE" : throw new TimeZoneNotFoundException()); - - Assert.ThrowsAsync( - async () => await this.Create( - reverse ? mockReader.Object : reader1, - reverse ? reader1 : mockReader.Object - ).ToListAsync() - ); - } - - TestOneThrows(reverse: false); - TestOneThrows(reverse: true); - } + TestOneThrows(reverse: false); + TestOneThrows(reverse: true); + } - [Timeout(10000)] // something's wrong if it's taking more than 15 seconds - [Test] - public void FuzzTest() - { - var pipe1 = new Pipe(); - var pipe2 = new Pipe(); + [Test, Timeout(10000)] // something's wrong if it's taking more than 10 seconds + public void FuzzTest() + { + var pipe1 = new Pipe(); + var pipe2 = new Pipe(); - var asyncEnumerable = this.Create(new StreamReader(pipe1.OutputStream), new StreamReader(pipe2.OutputStream)); + var asyncEnumerable = this.Create(new StreamReader(pipe1.OutputStream), new StreamReader(pipe2.OutputStream)); - var strings1 = Enumerable.Range(0, 2000).Select(_ => Guid.NewGuid().ToString()).ToArray(); - var strings2 = Enumerable.Range(0, 2300).Select(_ => Guid.NewGuid().ToString()).ToArray(); + var strings1 = Enumerable.Range(0, 2000).Select(_ => Guid.NewGuid().ToString()).ToArray(); + var strings2 = Enumerable.Range(0, 2300).Select(_ => Guid.NewGuid().ToString()).ToArray(); - static void WriteStrings(IReadOnlyList strings, TextWriter writer) + static void WriteStrings(IReadOnlyList strings, TextWriter writer) + { + var spinWait = default(SpinWait); + var random = new Random(Guid.NewGuid().GetHashCode()); + foreach (var line in strings) { - var spinWait = default(SpinWait); - var random = new Random(Guid.NewGuid().GetHashCode()); - foreach (var line in strings) + if (random.Next(110) == 1) { - if (random.Next(110) == 1) - { - spinWait.SpinOnce(); - } - - writer.WriteLine(line); + spinWait.SpinOnce(); } - } - - var task1 = Task.Run(() => - { - using StreamWriter writer1 = new(pipe1.InputStream); - WriteStrings(strings1, writer1); - }); - var task2 = Task.Run(() => - { - using StreamWriter writer2 = new(pipe2.InputStream); - WriteStrings(strings2, writer2); - }); - var consumeTask = asyncEnumerable.ToListAsync(); - Task.WaitAll(task1, task2, consumeTask); - CollectionAssert.AreEquivalent(strings1.Concat(strings2).ToList(), consumeTask.Result); + writer.WriteLine(line); + } } + + var task1 = Task.Run(() => + { + using StreamWriter writer1 = new(pipe1.InputStream); + WriteStrings(strings1, writer1); + }); + var task2 = Task.Run(() => + { + using StreamWriter writer2 = new(pipe2.InputStream); + WriteStrings(strings2, writer2); + }); + var consumeTask = asyncEnumerable.ToListAsync(); + Task.WaitAll(task1, task2, consumeTask); + + CollectionAssert.AreEquivalent(strings1.Concat(strings2).ToList(), consumeTask.Result); } +} - public static class AsyncEnumerableExtensions +public static class AsyncEnumerableExtensions +{ + public static async Task> ToListAsync(this IAsyncEnumerable strings) { - public static async Task> ToListAsync(this IAsyncEnumerable strings) - { - List result = new(); - await foreach (var item in strings) { result.Add(item); } - return result; - } + List result = []; + await foreach (var item in strings) { result.Add(item); } + return result; } } diff --git a/stylecop.analyzers.ruleset b/stylecop.analyzers.ruleset index 47b4531..aa143ab 100644 --- a/stylecop.analyzers.ruleset +++ b/stylecop.analyzers.ruleset @@ -14,6 +14,7 @@ + From 263d7dd4e2346eb1190e9284640f91bac407cd76 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sat, 29 Jun 2024 18:31:16 +0900 Subject: [PATCH 26/28] Do not wait consumeTask with other tasks, and revert everything else --- .../Streams/MergedLinesEnumerableTestBase.cs | 35 +++++++------------ 1 file changed, 13 insertions(+), 22 deletions(-) diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs index bea9070..8c54895 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs @@ -97,24 +97,24 @@ void TestOneThrows(bool reverse) TestOneThrows(reverse: true); } - [Test, Timeout(10000)] // something's wrong if it's taking more than 10 seconds - public void FuzzTest() + [Test, Timeout(5_000)] // something's wrong if it's taking more than 5 seconds + public async Task FuzzTest() { - var pipe1 = new Pipe(); - var pipe2 = new Pipe(); + Pipe pipe1 = new(), pipe2 = new(); var asyncEnumerable = this.Create(new StreamReader(pipe1.OutputStream), new StreamReader(pipe2.OutputStream)); var strings1 = Enumerable.Range(0, 2000).Select(_ => Guid.NewGuid().ToString()).ToArray(); var strings2 = Enumerable.Range(0, 2300).Select(_ => Guid.NewGuid().ToString()).ToArray(); - static void WriteStrings(IReadOnlyList strings, TextWriter writer) + static void WriteStrings(IReadOnlyList strings, Pipe pipe) { - var spinWait = default(SpinWait); - var random = new Random(Guid.NewGuid().GetHashCode()); + SpinWait spinWait = default; + Random random = new(Guid.NewGuid().GetHashCode()); + using StreamWriter writer = new(pipe.InputStream); foreach (var line in strings) { - if (random.Next(110) == 1) + if (random.Next(10) == 1) { spinWait.SpinOnce(); } @@ -122,21 +122,12 @@ static void WriteStrings(IReadOnlyList strings, TextWriter writer) writer.WriteLine(line); } } - - var task1 = Task.Run(() => - { - using StreamWriter writer1 = new(pipe1.InputStream); - WriteStrings(strings1, writer1); - }); - var task2 = Task.Run(() => - { - using StreamWriter writer2 = new(pipe2.InputStream); - WriteStrings(strings2, writer2); - }); - var consumeTask = asyncEnumerable.ToListAsync(); - Task.WaitAll(task1, task2, consumeTask); - CollectionAssert.AreEquivalent(strings1.Concat(strings2).ToList(), consumeTask.Result); + var task1 = Task.Run(() => WriteStrings(strings1, pipe1)); + var task2 = Task.Run(() => WriteStrings(strings2, pipe2)); + Task.WaitAll(task1, task2); // need to dispose the writer to end the stream + + CollectionAssert.AreEquivalent(strings1.Concat(strings2), await asyncEnumerable.ToListAsync()); } } From 7830e3e7705eac3ac0964810e6e3d02f9d06c6ba Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Sat, 29 Jun 2024 18:50:51 +0900 Subject: [PATCH 27/28] Try bumping the timeout, considering the CI pipeline --- MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs index ba3023a..3756d8a 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs @@ -97,7 +97,7 @@ void TestOneThrows(bool reverse) TestOneThrows(reverse: true); } - [Test, Timeout(5_000)] // something's wrong if it's taking more than 5 seconds + [Test, Timeout(10_000)] // something's wrong if it's taking more than 10 seconds public async Task FuzzTest() { Pipe pipe1 = new(), pipe2 = new(); From ff9af419fe8ad059b0ddafa53644933d7cfb9eb0 Mon Sep 17 00:00:00 2001 From: Jihoon Park Date: Mon, 1 Jul 2024 16:49:21 +0900 Subject: [PATCH 28/28] Try replacing Task.Run with an async method --- .../Streams/MergedLinesEnumerableTestBase.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs index 3756d8a..7ba3216 100644 --- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs +++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs @@ -107,8 +107,10 @@ public async Task FuzzTest() var strings1 = Enumerable.Range(0, 2000).Select(_ => Guid.NewGuid().ToString()).ToArray(); var strings2 = Enumerable.Range(0, 2300).Select(_ => Guid.NewGuid().ToString()).ToArray(); - static void WriteStrings(IReadOnlyList strings, Pipe pipe) + static async Task WriteStringsAsync(IReadOnlyList strings, Pipe pipe) { + await Task.CompletedTask; // to make compiler happy + SpinWait spinWait = default; Random random = new(Guid.NewGuid().GetHashCode()); using StreamWriter writer = new(pipe.InputStream); @@ -123,11 +125,12 @@ static void WriteStrings(IReadOnlyList strings, Pipe pipe) } } - var task1 = Task.Run(() => WriteStrings(strings1, pipe1)); - var task2 = Task.Run(() => WriteStrings(strings2, pipe2)); - Task.WaitAll(task1, task2); // need to dispose the writer to end the stream + var task1 = WriteStringsAsync(strings1, pipe1); + var task2 = WriteStringsAsync(strings2, pipe2); + var consumeTask = asyncEnumerable.ToListAsync(); + await Task.WhenAll(task1, task2, consumeTask); // need to dispose the writer to end the stream - CollectionAssert.AreEquivalent(strings1.Concat(strings2), await asyncEnumerable.ToListAsync()); + CollectionAssert.AreEquivalent(strings1.Concat(strings2), consumeTask.Result); } }