diff --git a/StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs index 4248b3b3..b79cf0dc 100644 --- a/StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/ConsoleViewModel.cs @@ -8,6 +8,7 @@ using Avalonia.Threading; using AvaloniaEdit.Document; using CommunityToolkit.Mvvm.ComponentModel; using Nito.AsyncEx; +using Nito.AsyncEx.Synchronous; using NLog; using StabilityMatrix.Core.Extensions; using StabilityMatrix.Core.Processes; @@ -454,9 +455,26 @@ public partial class ConsoleViewModel : ObservableObject, IDisposable public void Dispose() { updateCts?.Cancel(); - updateTask?.Dispose(); updateCts?.Dispose(); + if (updateTask is not null) + { + try + { + updateTask.WaitWithoutException( + new CancellationTokenSource(5000).Token); + updateTask.Dispose(); + } + catch (OperationCanceledException) + { + Logger.Error("During shutdown - Console update task cancellation timed out"); + } + catch (InvalidOperationException e) + { + Logger.Error(e, "During shutdown - Console update task cancellation failed"); + } + } + GC.SuppressFinalize(this); } } diff --git a/StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs index f3f0dd2d..f32e82ca 100644 --- a/StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/LaunchPageViewModel.cs @@ -433,8 +433,9 @@ public partial class LaunchPageViewModel : PageViewModelBase, IDisposable public void Dispose() { - Console.Dispose(); RunningPackage?.Shutdown(); + Console.Dispose(); + GC.SuppressFinalize(this); } } diff --git a/StabilityMatrix.Core/Extensions/StringExtensions.cs b/StabilityMatrix.Core/Extensions/StringExtensions.cs index b777f7f6..eb924bcc 100644 --- a/StabilityMatrix.Core/Extensions/StringExtensions.cs +++ b/StabilityMatrix.Core/Extensions/StringExtensions.cs @@ -27,25 +27,30 @@ public static class StringExtensions /// /// Converts string to repr /// - public static string ToRepr(this string str) + public static string ToRepr(this string? str) { + if (str is null) + { + return ""; + } using var writer = new StringWriter(); - using var provider = CodeDomProvider.CreateProvider("CSharp"); - - provider.GenerateCodeFromExpression( - new CodePrimitiveExpression(str), - writer, - new CodeGeneratorOptions {IndentString = "\t"}); + writer.Write("'"); + foreach (var ch in str) + { + writer.Write(ch switch + { + '\0' => "\\0", + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + // Non ascii + _ when ch > 127 || ch < 32 => $"\\u{(int) ch:x4}", + _ => ch.ToString() + }); + } + writer.Write("'"); - var literal = writer.ToString(); - // Replace split lines - literal = literal.Replace($"\" +{Environment.NewLine}\t\"", ""); - // Encode non-ascii characters - literal = EncodeNonAsciiCharacters(literal); - // Surround with single quotes - literal = literal.Trim('"'); - literal = $"'{literal}'"; - return literal; + return writer.ToString(); } /// diff --git a/StabilityMatrix.Core/Processes/AsyncStreamReader.cs b/StabilityMatrix.Core/Processes/AsyncStreamReader.cs index 798402c4..f42d0f2e 100644 --- a/StabilityMatrix.Core/Processes/AsyncStreamReader.cs +++ b/StabilityMatrix.Core/Processes/AsyncStreamReader.cs @@ -254,6 +254,9 @@ internal sealed class AsyncStreamReader : IDisposable else { // Send buffer up to this point, not including \r + // But skip if there's no content + if (currentIndex == lineStart) break; + var line = _sb.ToString(lineStart, currentIndex - lineStart); lock (_messageQueue) { diff --git a/StabilityMatrix.Tests/Core/AsyncStreamReaderTests.cs b/StabilityMatrix.Tests/Core/AsyncStreamReaderTests.cs index a04b58e3..ebf0e8e8 100644 --- a/StabilityMatrix.Tests/Core/AsyncStreamReaderTests.cs +++ b/StabilityMatrix.Tests/Core/AsyncStreamReaderTests.cs @@ -13,62 +13,68 @@ public class AsyncStreamReaderTests [DataTestMethod] // Test newlines handling for \r\n, \n - [DataRow("a\r\nb\nc", "a\r\n", "b\n", "c")] + [DataRow("a\r\nb\nc", "a\r\n", "b\n", "c", null)] // Carriage returns \r should be sent as is - [DataRow("a\rb\rc", "a", "\rb", "\rc")] - [DataRow("a1\ra2\nb1\rb2", "a1", "\ra2\n", "b1", "\rb2")] + [DataRow("a\rb\rc", "a", "\rb", "\rc", null)] + [DataRow("a1\ra2\nb1\rb2", "a1", "\ra2\n", "b1", "\rb2", null)] // Ansi escapes should be seperated - [DataRow("\x1b[A\x1b[A", "\x1b[A", "\x1b[A")] + [DataRow("\x1b[A\x1b[A", "\x1b[A", "\x1b[A", null)] // Mixed Ansi and newlines - [DataRow("a \x1b[A\r\n\r xyz", "a ", "\x1b[A", "\r\n", "\r xyz")] - public async Task TestRead(string source, params string[] expected) + [DataRow("a \x1b[A\r\n\r xyz", "a ", "\x1b[A", "\r\n", "\r xyz", null)] + public async Task TestRead(string source, params string?[] expected) { - var queue = new Queue(expected); + var results = new List(); var callback = new Action(s => { - Assert.IsTrue(queue.Count > 0); - Assert.AreEqual(queue.Dequeue(), s); + results.Add(s); }); - var stream = new MemoryStream(Encoding.UTF8.GetBytes(source)); - - // Make the reader - using var reader = new AsyncStreamReader(stream, callback, Encoding.UTF8); - - // Begin read line and wait until finish - reader.BeginReadLine(); - await reader.EOF; - - // Check if all expected strings were read - Assert.AreEqual(0, queue.Count, "Remaining: " + string.Join(", ", queue.ToArray() - .Select(s => (s ?? "").ToRepr()))); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(source)); + using (var reader = new AsyncStreamReader(stream, callback, Encoding.UTF8)) + { + // Begin read line and wait until finish + reader.BeginReadLine(); + // Wait for maximum 1 second + await reader.EOF.WaitAsync(new CancellationTokenSource(1000).Token); + } + + // Check expected output matches + Assert.IsTrue(expected.SequenceEqual(results.ToArray()), + "Results [{0}] do not match expected [{1}]", + string.Join(", ", results.Select(s => s?.ToRepr() ?? "")), + string.Join(", ", expected.Select(s => s?.ToRepr() ?? ""))); } [TestMethod] public async Task TestCarriageReturnHandling() { - // The previous buffer should be sent when \r is encountered - const string source = "dog\r\ncat\r123\r456"; - var stream = new MemoryStream(Encoding.UTF8.GetBytes(source)); + var expected = new[] {"dog\r\n", "cat", "\r123", "\r456", null}; - var queue = new Queue(new[] {"dog\r\n", "cat", "\r123", "\r456"}); + var results = new List(); var callback = new Action(s => { - Assert.IsTrue(queue.Count > 0); - Assert.AreEqual(queue.Dequeue(), s); + results.Add(s); }); - // Make the reader - using var reader = new AsyncStreamReader(stream, callback, Encoding.UTF8); + // The previous buffer should be sent when \r is encountered + const string source = "dog\r\ncat\r123\r456"; - // Begin read line and wait until finish - reader.BeginReadLine(); - await reader.EOF; + // Make the reader + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(source)); + using (var reader = new AsyncStreamReader(stream, callback, Encoding.UTF8)) + { + // Begin read line and wait until finish + reader.BeginReadLine(); + // Wait for maximum 1 second + await reader.EOF.WaitAsync(new CancellationTokenSource(1000).Token); + } // Check if all expected strings were read - Assert.AreEqual(0, queue.Count, "Remaining: " + string.Join(", ", queue.ToArray() - .Select(s => (s ?? "").ToRepr()))); + Assert.IsTrue(expected.SequenceEqual(results.ToArray()), + "Results [{0}] do not match expected [{1}]", + string.Join(", ", results.Select(s => s?.ToRepr() ?? "")), + string.Join(", ", expected.Select(s => s?.ToRepr() ?? ""))); } }