using System; using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Threading.Tasks.Dataflow; 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; namespace StabilityMatrix.Avalonia.ViewModels; public partial class ConsoleViewModel : ObservableObject, IDisposable, IAsyncDisposable { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private bool isDisposed; // Queue for console updates private BufferBlock buffer = new(); // Task that updates the console (runs on UI thread) private Task? updateTask; // Cancellation token source for updateTask private CancellationTokenSource? updateCts; public bool IsUpdatesRunning => updateTask?.IsCompleted == false; [ObservableProperty] private TextDocument document = new(); /// /// Current offset for write operations. /// private int writeCursor; /// /// Lock for accessing /// private readonly AsyncLock writeCursorLock = new(); /// /// Timeout for acquiring locks on /// // ReSharper disable once MemberCanBePrivate.Global public TimeSpan WriteCursorLockTimeout { get; init; } = TimeSpan.FromMilliseconds(100); /// /// Gets a cancellation token using the cursor lock timeout /// private CancellationToken WriteCursorLockTimeoutToken => new CancellationTokenSource(WriteCursorLockTimeout).Token; /// /// Event invoked when an ApcMessage of type Input is received. /// public event EventHandler? ApcInput; /// /// Starts update task for processing Post messages. /// /// If update task is already running public void StartUpdates() { if (updateTask is not null) { throw new InvalidOperationException("Update task is already running"); } updateCts = new CancellationTokenSource(); updateTask = Dispatcher.UIThread.InvokeAsync(ConsoleUpdateLoop, DispatcherPriority.Send); } /// /// Cancels the update task and waits for it to complete. /// public async Task StopUpdatesAsync() { Logger.Trace($"Stopping console updates, current buffer items: {buffer.Count}"); // First complete the buffer buffer.Complete(); // Wait for buffer to complete, max 3 seconds var completionCts = new CancellationTokenSource(3000); try { await buffer.Completion.WaitAsync(completionCts.Token); } catch (TaskCanceledException e) { // We can still continue since this just means we lose // some remaining output Logger.Warn("Buffer completion timed out: " + e.Message); } // Cancel update task updateCts?.Cancel(); updateCts = null; // Wait for update task if (updateTask is not null) { await updateTask; updateTask = null; } Logger.Trace($"Stopped console updates with {buffer.Count} buffer items remaining"); } /// /// Clears the console and sets a new buffer. /// This also resets the write cursor to 0. /// public async Task Clear() { // Clear document Document.Text = string.Empty; // Reset write cursor await ResetWriteCursor(); // Clear buffer and create new one buffer.Complete(); buffer = new BufferBlock(); } /// /// Resets the write cursor to be equal to the document length. /// public async Task ResetWriteCursor() { using (await writeCursorLock.LockAsync(WriteCursorLockTimeoutToken)) { Logger.ConditionalTrace($"Reset cursor to end: ({writeCursor} -> {Document.TextLength})"); writeCursor = Document.TextLength; } DebugPrintDocument(); } private async Task ConsoleUpdateLoop() { // This must be run in the UI thread Dispatcher.UIThread.VerifyAccess(); // Get cancellation token var ct = updateCts?.Token ?? throw new InvalidOperationException("Update cancellation token must be set"); try { while (!ct.IsCancellationRequested) { ProcessOutput output; try { output = await buffer.ReceiveAsync(ct); } catch (InvalidOperationException e) { // Thrown when buffer is completed, convert to OperationCanceledException throw new OperationCanceledException("Update buffer completed", e); } var outputType = output.IsStdErr ? "stderr" : "stdout"; Logger.ConditionalTrace( $"Processing: [{outputType}] (Text = {output.Text.ToRepr()}, " + $"Raw = {output.RawText?.ToRepr()}, " + $"CarriageReturn = {output.CarriageReturn}, " + $"CursorUp = {output.CursorUp}, " + $"AnsiCommand = {output.AnsiCommand})" ); // Link the cancellation token to the write cursor lock timeout var linkedCt = CancellationTokenSource.CreateLinkedTokenSource(ct, WriteCursorLockTimeoutToken).Token; using (await writeCursorLock.LockAsync(linkedCt)) { ConsoleUpdateOne(output); } } } catch (OperationCanceledException e) { Logger.Debug($"Console update loop canceled: {e.Message}"); } catch (Exception e) { // Log other errors and continue here to not crash the UI thread Logger.Error(e, $"Unexpected error in console update loop: {e.GetType().Name} {e.Message}"); } } /// /// Handle one instance of ProcessOutput. /// Calls to this function must be synchronized with /// /// Not checked, but must be run in the UI thread. private void ConsoleUpdateOne(ProcessOutput output) { Debug.Assert(Dispatcher.UIThread.CheckAccess()); // Check for Apc messages if (output.ApcMessage is not null) { // Handle Apc message, for now just input audit events var message = output.ApcMessage.Value; if (message.Type == ApcType.Input) { ApcInput?.Invoke(this, message); } // Ignore further processing return; } // If we have a carriage return, // start current write at the beginning of the current line if (output.CarriageReturn > 0) { var currentLine = Document.GetLineByOffset(writeCursor); // Get the start of current line as new write cursor var lineStartOffset = currentLine.Offset; // See if we need to move the cursor if (lineStartOffset == writeCursor) { Logger.ConditionalTrace( $"Cursor already at start for carriage return " + $"(offset = {lineStartOffset}, line = {currentLine.LineNumber})" ); } else { // Also remove everything on current line // We'll temporarily do this for now to fix progress var lineEndOffset = currentLine.EndOffset; var lineLength = lineEndOffset - lineStartOffset; Document.Remove(lineStartOffset, lineLength); Logger.ConditionalTrace( $"Moving cursor to start for carriage return " + $"({writeCursor} -> {lineStartOffset})" ); writeCursor = lineStartOffset; } } // Write new text if (!string.IsNullOrEmpty(output.Text)) { DirectWriteLinesToConsole(output.Text); } // Handle cursor movements if (output.CursorUp > 0) { // Get the line and column of the current cursor var currentLocation = Document.GetLocation(writeCursor); if (currentLocation.Line == 1) { // We are already on the first line, ignore Logger.ConditionalTrace($"Cursor up: Already on first line"); } else { // We want to move up one line var targetLocation = new TextLocation(currentLocation.Line - 1, currentLocation.Column); var targetOffset = Document.GetOffset(targetLocation); // Update cursor to target offset Logger.ConditionalTrace( $"Cursor up: Moving (line {currentLocation.Line}, {writeCursor})" + $" -> (line {targetLocation.Line}, {targetOffset})" ); writeCursor = targetOffset; } } // Handle erase commands, different to cursor move as they don't move the cursor // We'll insert blank spaces instead if (output.AnsiCommand.HasFlag(AnsiCommand.EraseLine)) { // Get the current line, we'll insert spaces from start to end var currentLine = Document.GetLineByOffset(writeCursor); // Must be smaller than total lines currentLine = currentLine.LineNumber < Document.LineCount ? currentLine : Document.GetLineByNumber(Document.LineCount - 1); // Make some spaces to insert var spaces = new string(' ', currentLine.Length); // Insert the text Logger.ConditionalTrace($"Erasing line {currentLine.LineNumber}: (length = {currentLine.Length})"); using (Document.RunUpdate()) { Document.Replace(currentLine.Offset, currentLine.Length, spaces); } } DebugPrintDocument(); } /// /// Write text potentially containing line breaks to the console. /// This call will hold a upgradeable read lock /// private void DirectWriteLinesToConsole(string text) { // When our cursor is not at end, newlines should be interpreted as commands to // move cursor forward to the next linebreak instead of inserting a newline. // If text contains no newlines, we can just call DirectWriteToConsole // Also if cursor is equal to document length if (!text.Contains(Environment.NewLine) || writeCursor == Document.TextLength) { DirectWriteToConsole(text); return; } // Otherwise we need to handle how linebreaks are treated // Split text into lines var lines = text.Split(Environment.NewLine).ToList(); foreach (var lineText in lines.SkipLast(1)) { // Insert text DirectWriteToConsole(lineText); // Set cursor to start of next line, if we're not already there var currentLine = Document.GetLineByOffset(writeCursor); // If next line is available, move cursor to start of next line if (currentLine.LineNumber < Document.LineCount) { var nextLine = Document.GetLineByNumber(currentLine.LineNumber + 1); Logger.ConditionalTrace( $"Moving cursor to start of next line " + $"({writeCursor} -> {nextLine.Offset})" ); writeCursor = nextLine.Offset; } else { // Otherwise move to end of current line, and direct insert a newline var lineEndOffset = currentLine.EndOffset; Logger.ConditionalTrace( $"Moving cursor to end of current line " + $"({writeCursor} -> {lineEndOffset})" ); writeCursor = lineEndOffset; DirectWriteToConsole(Environment.NewLine); } } } /// /// Write text to the console, does not handle newlines. /// This should probably only be used by /// This call will hold a upgradeable read lock /// private void DirectWriteToConsole(string text) { using (Document.RunUpdate()) { // Need to replace text first if cursor lower than document length var replaceLength = Math.Min(Document.TextLength - writeCursor, text.Length); if (replaceLength > 0) { var newText = text[..replaceLength]; Logger.ConditionalTrace( $"Replacing: (cursor = {writeCursor}, length = {replaceLength}, " + $"text = {Document.GetText(writeCursor, replaceLength).ToRepr()} " + $"-> {newText.ToRepr()})" ); Document.Replace(writeCursor, replaceLength, newText); writeCursor += replaceLength; } // If we replaced less than content.Length, we need to insert the rest var remainingLength = text.Length - replaceLength; if (remainingLength > 0) { var textToInsert = text[replaceLength..]; Logger.ConditionalTrace($"Inserting: (cursor = {writeCursor}, " + $"text = {textToInsert.ToRepr()})"); Document.Insert(writeCursor, textToInsert); writeCursor += textToInsert.Length; } } } /// /// Debug function to print the current document to the console. /// Includes formatted cursor position. /// [Conditional("DEBUG")] private void DebugPrintDocument() { if (!Logger.IsTraceEnabled) return; var text = Document.Text; // Add a number for each line // Add an arrow line for where the cursor is, for example (cursor on offset 3): // // 1 | This is the first line // ~~~~~~~^ (3) // 2 | This is the second line // var lines = text.Split(Environment.NewLine).ToList(); var numberPadding = lines.Count.ToString().Length; for (var i = 0; i < lines.Count; i++) { lines[i] = $"{(i + 1).ToString().PadLeft(numberPadding)} | {lines[i]}"; } var cursorLine = Document.GetLineByOffset(writeCursor); var cursorLineOffset = writeCursor - cursorLine.Offset; // Need to account for padding + line number + space + cursor line offset var linePadding = numberPadding + 3 + cursorLineOffset; var cursorLineArrow = new string('~', linePadding) + $"^ ({writeCursor})"; // If more than line count, append to end if (cursorLine.LineNumber >= lines.Count) { lines.Add(cursorLineArrow); } else { lines.Insert(cursorLine.LineNumber, cursorLineArrow); } var textWithCursor = string.Join(Environment.NewLine, lines); Logger.ConditionalTrace("[Current Document]"); Logger.ConditionalTrace(textWithCursor); } /// /// Posts an update to the console /// Safe to call on non-UI threads /// public void Post(ProcessOutput output) { // If update task is running, send to buffer if (updateTask != null) { buffer.Post(output); return; } // Otherwise, use manual update one Logger.Debug("Synchronous post update to console: {@Output}", output); Dispatcher.UIThread.Post(() => ConsoleUpdateOne(output)); } /// /// Posts an update to the console. /// Helper for calling Post(ProcessOutput) with strings /// public void Post(string text) { Post(new ProcessOutput { Text = text }); } /// /// Posts an update to the console. /// Equivalent to Post(text + Environment.NewLine) /// public void PostLine(string text) { Post(new ProcessOutput { Text = text + Environment.NewLine }); } public void Dispose() { if (isDisposed) return; updateCts?.Cancel(); updateCts?.Dispose(); updateCts = null; buffer.Complete(); if (updateTask is not null) { Logger.Debug("Shutting down console update task"); try { updateTask.WaitWithoutException(new CancellationTokenSource(1000).Token); updateTask.Dispose(); updateTask = null; } catch (OperationCanceledException) { Logger.Warn("During shutdown - Console update task cancellation timed out"); } catch (InvalidOperationException e) { Logger.Warn(e, "During shutdown - Console update task dispose failed"); } } isDisposed = true; GC.SuppressFinalize(this); } public async ValueTask DisposeAsync() { if (isDisposed) return; updateCts?.Cancel(); updateCts?.Dispose(); updateCts = null; if (updateTask is not null) { Logger.Debug("Waiting for console update task shutdown..."); await updateTask; updateTask.Dispose(); updateTask = null; Logger.Debug("Console update task shutdown complete"); } isDisposed = true; GC.SuppressFinalize(this); } }