diff --git a/MedallionShell.Tests/MedallionShell.Tests.csproj b/MedallionShell.Tests/MedallionShell.Tests.csproj
index e449524..43b8a45 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
@@ -17,15 +17,16 @@
-
-
+
+
+
All
-
+
diff --git a/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs b/MedallionShell.Tests/Streams/AsyncEnumerableAdapter.cs
new file mode 100644
index 0000000..2721441
--- /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 default;
+ }
+
+ public ValueTask MoveNextAsync() => new(this.enumerator.MoveNext());
+ }
+}
diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTest.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTest.cs
deleted file mode 100644
index c62a0ae..0000000
--- a/MedallionShell.Tests/Streams/MergedLinesEnumerableTest.cs
+++ /dev/null
@@ -1,137 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Text;
-using System.Threading;
-using System.Threading.Tasks;
-using Medallion.Shell.Streams;
-using Moq;
-using NUnit.Framework;
-
-namespace Medallion.Shell.Tests.Streams
-{
- public class MergedLinesEnumerableTest
- {
- [Test]
- public void 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(["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(["a", "bb", "ccc"])
- .ShouldEqual(true, string.Join(", ", list2));
- }
-
- [Test]
- public void TestBothAreEmpty()
- {
- var list = new MergedLinesEnumerable(new StringReader(string.Empty), new StringReader(string.Empty)).ToList();
- list.Count.ShouldEqual(0, string.Join(", ", list));
- }
-
- [Test]
- public void TestBothArePopulatedEqualSizes()
- {
- var list = new MergedLinesEnumerable(
- new StringReader("a\nbb\nccc"),
- new StringReader("1\r\n22\r\n333")
- )
- .ToList();
- string.Join(", ", list).ShouldEqual("a, 1, bb, 22, ccc, 333");
- }
-
- [Test]
- public void TestBothArePopulatedDifferenceSizes()
- {
- 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();
- string.Join(", ", list1).ShouldEqual("x, 1, y, 2, z, 3, 4, 5");
-
- var list2 = new MergedLinesEnumerable(new StringReader(lines2), new StringReader(lines1))
- .ToList();
- 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());
- }
-
- [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.Throws(
- () => new MergedLinesEnumerable(
- reverse ? mockReader.Object : reader1,
- reverse ? reader1 : mockReader.Object
- )
- .ToList()
- );
- }
-
- TestOneThrows(reverse: false);
- TestOneThrows(reverse: true);
- }
-
- [Test]
- public void FuzzTest()
- {
- var pipe1 = new Pipe();
- var pipe2 = new Pipe();
-
- var enumerable = new MergedLinesEnumerable(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();
-
- 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(() => enumerable.ToList());
- Task.WaitAll(task1, task2, consumeTask);
-
- CollectionAssert.AreEquivalent(strings1.Concat(strings2).ToList(), consumeTask.Result);
- }
- }
-}
diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs
new file mode 100644
index 0000000..d0d9b55
--- /dev/null
+++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestAsync.cs
@@ -0,0 +1,13 @@
+#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_0_OR_GREATER
+using System.Collections.Generic;
+using System.IO;
+using Medallion.Shell.Streams;
+
+namespace Medallion.Shell.Tests.Streams;
+
+public class MergedLinesEnumerableTestAsync : MergedLinesEnumerableTestBase
+{
+ protected override IAsyncEnumerable Create(TextReader reader1, TextReader reader2) =>
+ new MergedLinesEnumerable(reader1, reader2);
+}
+#endif
\ No newline at end of file
diff --git a/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs
new file mode 100644
index 0000000..7ba3216
--- /dev/null
+++ b/MedallionShell.Tests/Streams/MergedLinesEnumerableTestBase.cs
@@ -0,0 +1,145 @@
+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 abstract class MergedLinesEnumerableTestBase
+{
+ protected abstract IAsyncEnumerable Create(TextReader reader1, TextReader reader2);
+
+ [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(["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"])
+ .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 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", ["x", "y", "z"]);
+ var lines2 = string.Join("\n", ["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 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 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);
+ }
+
+ [Test, Timeout(10_000)] // something's wrong if it's taking more than 10 seconds
+ public async Task FuzzTest()
+ {
+ 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 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);
+ foreach (var line in strings)
+ {
+ if (random.Next(10) == 1)
+ {
+ spinWait.SpinOnce();
+ }
+
+ writer.WriteLine(line);
+ }
+ }
+
+ 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), consumeTask.Result);
+ }
+}
+
+public static class AsyncEnumerableExtensions
+{
+ public static async Task> ToListAsync(this IAsyncEnumerable strings)
+ {
+ List result = [];
+ await foreach (var item in strings) { result.Add(item); }
+ return result;
+ }
+}
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/MedallionShell.csproj b/MedallionShell/MedallionShell.csproj
index 3efaeac..8d6498b 100644
--- a/MedallionShell/MedallionShell.csproj
+++ b/MedallionShell/MedallionShell.csproj
@@ -45,7 +45,7 @@
-
+
@@ -80,4 +80,7 @@
MedallionShell.ProcessSignaler.exe
+
+
+
\ No newline at end of file
diff --git a/MedallionShell/Streams/MergedLinesEnumerable.cs b/MedallionShell/Streams/MergedLinesEnumerable.cs
index 93aa736..75d2375 100644
--- a/MedallionShell/Streams/MergedLinesEnumerable.cs
+++ b/MedallionShell/Streams/MergedLinesEnumerable.cs
@@ -1,94 +1,149 @@
using System;
using System.Collections;
using System.Collections.Generic;
+using System.Diagnostics;
using System.IO;
using System.Linq;
-using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Medallion.Shell.Streams
{
- internal sealed class MergedLinesEnumerable(TextReader standardOutput, TextReader standardError) : IEnumerable
+ internal sealed class MergedLinesEnumerable :
+#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_0_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();
+
+ 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()
{
- var tasks = new List(capacity: 2);
- tasks.Add(new ReaderAndTask(standardOutput));
- tasks.Add(new ReaderAndTask(standardError));
+ List tasks = [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
+ do
+ {
+ var nextLine = this.GetNextLineOrDefaultAsync(tasks).GetAwaiter().GetResult();
+ if (nextLine is null) { yield break; }
+ yield return nextLine;
+ }
+ while (tasks.Count != 1);
- TextReader remaining;
- while (true)
+ var remaining = tasks[0].Reader;
+ while (remaining.ReadLine() is { } line)
{
- 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;
- }
- }
+ yield return line;
}
+ }
+
+ 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.");
- // phase 2: finish reading the remaining stream
+ if (tasks.Count == 0)
+ {
+ tasks = [new(this.standardOutput, cancellationToken), new(this.standardError, cancellationToken)];
+ }
- string? line;
- while ((line = remaining.ReadLine()) != null)
+ 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));
+ return nextLine;
+ }
+ else if (await tasks[0].Task.ConfigureAwait(false) is { } otherAsyncLine)
+ {
+ return otherAsyncLine;
+ }
+ return null;
+ }
+
+#if NETCOREAPP3_0_OR_GREATER || NETSTANDARD2_0_OR_GREATER
+ public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default)
+ {
+ this.AssertNoMultipleEnumeration();
+
+ return this.GetAsyncEnumeratorInternal(cancellationToken);
+ }
+
+ private async IAsyncEnumerator GetAsyncEnumeratorInternal(CancellationToken cancellationToken)
+ {
+ List tasks = [new(this.standardOutput, cancellationToken), new(this.standardError, cancellationToken)];
+
+ do
+ {
+ var nextLine = await this.GetNextLineOrDefaultAsync(tasks, cancellationToken).ConfigureAwait(false);
+ if (nextLine is null) { yield break; }
+ yield return nextLine;
+ }
+ while (tasks.Count != 1);
+
+ 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;
}
}
+#endif
- private struct ReaderAndTask(TextReader reader) : IEquatable
+ private readonly struct ReaderAndTask : IEquatable
{
- public TextReader Reader { get; } = reader;
- public Task Task { get; } = reader.ReadLineAsync();
+ 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; }
+ public Task Task { get; }
public bool Equals(ReaderAndTask that) => this.Reader == that.Reader && this.Task == that.Task;
diff --git a/README.md b/README.md
index 15bfb20..55a33c7 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,4 @@
-MedallionShell
-==============
+# MedallionShell
MedallionShell vastly simplifies working with processes in .NET.
@@ -47,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#
@@ -70,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.
@@ -120,7 +142,7 @@ Linux: [
+ - 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 +157,7 @@ Linux: [` 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
diff --git a/stylecop.analyzers.ruleset b/stylecop.analyzers.ruleset
index fe9b573..3b8ef37 100644
--- a/stylecop.analyzers.ruleset
+++ b/stylecop.analyzers.ruleset
@@ -14,6 +14,7 @@
+