// Based on System.Diagnostics.AsyncStreamReader // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; using System.Text; using StabilityMatrix.Core.Extensions; namespace StabilityMatrix.Core.Processes; /// /// Modified from System.Diagnostics.AsyncStreamReader to support terminal processing. /// /// Currently has these modifications: /// - Carriage returns do not count as newlines '\r'. /// - APC messages are sent immediately without needing a newline. /// /// /// [SuppressMessage("ReSharper", "InconsistentNaming")] internal sealed class AsyncStreamReader : IDisposable { private const int DefaultBufferSize = 1024; // Byte buffer size private readonly Stream _stream; private readonly Decoder _decoder; private readonly byte[] _byteBuffer; private readonly char[] _charBuffer; // Delegate to call user function. private readonly Action _userCallBack; private readonly CancellationTokenSource _cts; private Task? _readToBufferTask; private readonly Queue _messageQueue; private StringBuilder? _sb; private bool _bLastCarriageReturn; private bool _cancelOperation; // Cache the last position scanned in sb when searching for lines. private int _currentLinePos; // (new) Flag to send next buffer immediately private bool _sendNextBufferImmediately; // Creates a new AsyncStreamReader for the given stream. The // character encoding is set by encoding and the buffer size, // in number of 16-bit characters, is set by bufferSize. internal AsyncStreamReader(Stream stream, Action callback, Encoding encoding) { Debug.Assert(stream != null && encoding != null && callback != null, "Invalid arguments!"); Debug.Assert(stream.CanRead, "Stream must be readable!"); _stream = stream; _userCallBack = callback; _decoder = encoding.GetDecoder(); _byteBuffer = new byte[DefaultBufferSize]; // This is the maximum number of chars we can get from one iteration in loop inside ReadBuffer. // Used so ReadBuffer can tell when to copy data into a user's char[] directly, instead of our internal char[]. var maxCharsPerBuffer = encoding.GetMaxCharCount(DefaultBufferSize); _charBuffer = new char[maxCharsPerBuffer]; _cts = new CancellationTokenSource(); _messageQueue = new Queue(); } // User calls BeginRead to start the asynchronous read internal void BeginReadLine() { _cancelOperation = false; if (_sb == null) { _sb = new StringBuilder(DefaultBufferSize); _readToBufferTask = Task.Run((Func)ReadBufferAsync); } else { FlushMessageQueue(rethrowInNewThread: false); } } internal void CancelOperation() { _cancelOperation = true; } // This is the async callback function. Only one thread could/should call this. private async Task ReadBufferAsync() { while (true) { try { var bytesRead = await _stream.ReadAsync(new Memory(_byteBuffer), _cts.Token).ConfigureAwait(false); if (bytesRead == 0) break; var charLen = _decoder.GetChars(_byteBuffer, 0, bytesRead, _charBuffer, 0); Debug.WriteLine($"AsyncStreamReader - Read {charLen} chars: " + $"{new string(_charBuffer, 0, charLen).ToRepr()}"); _sb!.Append(_charBuffer, 0, charLen); MoveLinesFromStringBuilderToMessageQueue(); } catch (IOException) { // We should ideally consume errors from operations getting cancelled // so that we don't crash the unsuspecting parent with an unhandled exc. // This seems to come in 2 forms of exceptions (depending on platform and scenario), // namely OperationCanceledException and IOException (for errorcode that we don't // map explicitly). break; // Treat this as EOF } catch (OperationCanceledException) { // We should consume any OperationCanceledException from child read here // so that we don't crash the parent with an unhandled exc break; // Treat this as EOF } // If user's delegate throws exception we treat this as EOF and // completing without processing current buffer content if (FlushMessageQueue(rethrowInNewThread: true)) { return; } } // We're at EOF, process current buffer content and flush message queue. lock (_messageQueue) { if (_sb!.Length != 0) { var remaining = _sb.ToString(); _messageQueue.Enqueue(remaining); _sb.Length = 0; Debug.WriteLine($"AsyncStreamReader - Reached EOF, sent remaining buffer: {remaining}"); } _messageQueue.Enqueue(null); } FlushMessageQueue(rethrowInNewThread: true); } // Send remaining buffer [MethodImpl(MethodImplOptions.AggressiveInlining)] private void SendRemainingBuffer() { lock (_messageQueue) { if (_sb!.Length == 0) return; _messageQueue.Enqueue(_sb.ToString()); _sb.Length = 0; } } // Send remaining buffer from index [MethodImpl(MethodImplOptions.AggressiveInlining)] private void SendRemainingBuffer(int startIndex) { lock (_messageQueue) { if (_sb!.Length == 0) return; _messageQueue.Enqueue(_sb.ToString(startIndex, _sb.Length - startIndex)); _sb.Length = 0; } } // Sends a message to the queue if not null or empty private void SendToQueue(string? message) { if (string.IsNullOrEmpty(message)) return; lock (_messageQueue) { _messageQueue.Enqueue(message); } } // Read lines stored in StringBuilder and the buffer we just read into. // A line is defined as a sequence of characters followed by // a carriage return ('\r'), a line feed ('\n'), or a carriage return // immediately followed by a line feed. The resulting string does not // contain the terminating carriage return and/or line feed. The returned // value is null if the end of the input stream has been reached. private void MoveLinesFromStringBuilderToMessageQueue() { var currentIndex = _currentLinePos; var lineStart = 0; var len = _sb!.Length; // If flagged, send next buffer immediately if (_sendNextBufferImmediately) { SendRemainingBuffer(); _sendNextBufferImmediately = false; return; } // skip a beginning '\n' character of new block if last block ended // with '\r' if (_bLastCarriageReturn && len > 0 && _sb[0] == '\n') { currentIndex = 1; lineStart = 1; _bLastCarriageReturn = false; } while (currentIndex < len) { var ch = _sb[currentIndex]; // Note the following common line feed chars: // \n - UNIX \r\n - DOS switch (ch) { case '\n': { // Include the '\n' as part of line. var line = _sb.ToString(lineStart, currentIndex - lineStart + 1); lineStart = currentIndex + 1; lock (_messageQueue) { _messageQueue.Enqueue(line); } break; } // \r\n - Windows // \r alone is parsed as carriage return case '\r': { // when next char is \n, we found \r\n - linebreak if (currentIndex + 1 < len && _sb[currentIndex + 1] == '\n') { // Include the '\r\n' as part of line. var line = _sb.ToString(lineStart, currentIndex - lineStart + 2); lock (_messageQueue) { _messageQueue.Enqueue(line); } // Advance 2 chars for \r\n lineStart = currentIndex + 2; // Increment one extra plus the end of the loop increment currentIndex++; } else { // Send buffer up to this point, not including \r var line = _sb.ToString(lineStart, currentIndex - lineStart); lock (_messageQueue) { _messageQueue.Enqueue(line); } // Set line start to current index lineStart = currentIndex; } break; } // Additional handling for Apc escape messages case ApcParser.ApcEscape: { // Unconditionally consume until StEscape // Look for index of StEscape var searchIndex = currentIndex; while (searchIndex < len && _sb[searchIndex] != ApcParser.StEscape) { searchIndex++; } // If we found StEscape, we have a complete APC message if (searchIndex < len) { // Include the StEscape as part of line. var line = _sb.ToString(lineStart, searchIndex - lineStart + 1); lock (_messageQueue) { _messageQueue.Enqueue(line); } Debug.WriteLine($"AsyncStreamReader - Sent Apc: '{line}'"); // Flag to send the next buffer immediately _sendNextBufferImmediately = true; // Advance currentIndex and lineStart to StEscape // lineStart = searchIndex + 1; currentIndex = searchIndex; var remainingStart = currentIndex + 1; var remainingStr = _sb.ToString(remainingStart, _sb.Length - remainingStart); Debug.WriteLine($"AsyncStreamReader - Sending remaining buffer: '{remainingStr}'"); // Send the rest of the buffer immediately SendRemainingBuffer(currentIndex + 1); return; } // Otherwise continue without any other changes break; } // If we receive an Ansi escape, send the existing buffer immediately // Kind of behaves like newlines case '\u001b': { // Unlike '\n', this char is not included in the line var line = _sb.ToString(lineStart, currentIndex - lineStart); SendToQueue(line); // Set line start to current index lineStart = currentIndex; // Look ahead and match the escape sequence var remaining = _sb.ToString(currentIndex, len - currentIndex); var result = AnsiParser.AnsiEscapeSequenceRegex().Match(remaining); // If we found a match, send the escape sequence match, and move forward if (result.Success) { var escapeSequence = result.Value; SendToQueue(escapeSequence); Debug.WriteLine($"AsyncStreamReader - Sent Ansi escape sequence: {escapeSequence.ToRepr()}"); // Advance currentIndex and lineStart to end of escape sequence // minus 1 since we will increment currentIndex at the end of the loop lineStart = currentIndex + escapeSequence.Length; currentIndex = lineStart - 1; } else { Debug.WriteLine($"AsyncStreamReader - No match for Ansi escape sequence: {remaining.ToRepr()}"); } break; } } currentIndex++; } if (len > 0 && _sb[len - 1] == '\r') { _bLastCarriageReturn = true; } // Keep the rest characters which can't form a new line in string builder. if (lineStart < len) { if (lineStart == 0) { // we found no linebreaks, in this case we cache the position // so next time we don't have to restart from the beginning _currentLinePos = currentIndex; } else { _sb.Remove(0, lineStart); _currentLinePos = 0; } } else { _sb.Length = 0; _currentLinePos = 0; } } // If everything runs without exception, returns false. // If an exception occurs and rethrowInNewThread is true, returns true. // If an exception occurs and rethrowInNewThread is false, the exception propagates. private bool FlushMessageQueue(bool rethrowInNewThread) { try { // Keep going until we're out of data to process. while (true) { // Get the next line (if there isn't one, we're done) and // invoke the user's callback with it. string? line; lock (_messageQueue) { if (_messageQueue.Count == 0) { break; } line = _messageQueue.Dequeue(); } if (!_cancelOperation) { _userCallBack(line); // invoked outside of the lock } } return false; } catch (Exception e) { // If rethrowInNewThread is true, we can't let the exception propagate synchronously on this thread, // so propagate it in a thread pool thread and return true to indicate to the caller that this failed. // Otherwise, let the exception propagate. if (rethrowInNewThread) { ThreadPool.QueueUserWorkItem(edi => ((ExceptionDispatchInfo)edi!).Throw(), ExceptionDispatchInfo.Capture(e)); return true; } throw; } } internal Task EOF => _readToBufferTask ?? Task.CompletedTask; public void Dispose() { _cts.Cancel(); } }