You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
441 lines
16 KiB
441 lines
16 KiB
// 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; |
|
|
|
/// <summary> |
|
/// 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. |
|
/// |
|
/// <seealso cref="ApcParser"/> |
|
/// </summary> |
|
[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<string?> _userCallBack; |
|
|
|
private readonly CancellationTokenSource _cts; |
|
private Task? _readToBufferTask; |
|
private readonly Queue<string?> _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<string?> 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<string?>(); |
|
} |
|
|
|
// User calls BeginRead to start the asynchronous read |
|
internal void BeginReadLine() |
|
{ |
|
_cancelOperation = false; |
|
|
|
if (_sb == null) |
|
{ |
|
_sb = new StringBuilder(DefaultBufferSize); |
|
_readToBufferTask = Task.Run((Func<Task>)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<byte>(_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; |
|
|
|
// 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 |
|
// But skip if there's no content |
|
if (currentIndex == lineStart) |
|
break; |
|
|
|
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; |
|
} |
|
|
|
// If flagged, send remaining buffer immediately |
|
if (_sendNextBufferImmediately) |
|
{ |
|
SendRemainingBuffer(); |
|
_sendNextBufferImmediately = false; |
|
return; |
|
} |
|
|
|
// 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(); |
|
} |
|
}
|
|
|