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.
536 lines
18 KiB
536 lines
18 KiB
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 NLog; |
|
using Nito.AsyncEx; |
|
using Nito.AsyncEx.Synchronous; |
|
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<ProcessOutput> 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(); |
|
|
|
/// <summary> |
|
/// Current offset for write operations. |
|
/// </summary> |
|
private int writeCursor; |
|
|
|
/// <summary> |
|
/// Lock for accessing <see cref="writeCursor"/> |
|
/// </summary> |
|
private readonly AsyncLock writeCursorLock = new(); |
|
|
|
/// <summary> |
|
/// Timeout for acquiring locks on <see cref="writeCursor"/> |
|
/// </summary> |
|
// ReSharper disable once MemberCanBePrivate.Global |
|
public TimeSpan WriteCursorLockTimeout { get; init; } = TimeSpan.FromMilliseconds(100); |
|
|
|
/// <summary> |
|
/// Gets a cancellation token using the cursor lock timeout |
|
/// </summary> |
|
private CancellationToken WriteCursorLockTimeoutToken => new CancellationTokenSource(WriteCursorLockTimeout).Token; |
|
|
|
/// <summary> |
|
/// Event invoked when an ApcMessage of type Input is received. |
|
/// </summary> |
|
public event EventHandler<ApcMessage>? ApcInput; |
|
|
|
/// <summary> |
|
/// Starts update task for processing Post messages. |
|
/// </summary> |
|
/// <exception cref="InvalidOperationException">If update task is already running</exception> |
|
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); |
|
} |
|
|
|
/// <summary> |
|
/// Cancels the update task and waits for it to complete. |
|
/// </summary> |
|
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"); |
|
} |
|
|
|
/// <summary> |
|
/// Clears the console and sets a new buffer. |
|
/// This also resets the write cursor to 0. |
|
/// </summary> |
|
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<ProcessOutput>(); |
|
} |
|
|
|
/// <summary> |
|
/// Resets the write cursor to be equal to the document length. |
|
/// </summary> |
|
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}"); |
|
} |
|
} |
|
|
|
/// <summary> |
|
/// Handle one instance of ProcessOutput. |
|
/// Calls to this function must be synchronized with <see cref="writeCursorLock"/> |
|
/// </summary> |
|
/// <remarks>Not checked, but must be run in the UI thread.</remarks> |
|
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(); |
|
} |
|
|
|
/// <summary> |
|
/// Write text potentially containing line breaks to the console. |
|
/// <remarks>This call will hold a upgradeable read lock</remarks> |
|
/// </summary> |
|
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); |
|
} |
|
} |
|
} |
|
|
|
/// <summary> |
|
/// Write text to the console, does not handle newlines. |
|
/// This should probably only be used by <see cref="DirectWriteLinesToConsole"/> |
|
/// <remarks>This call will hold a upgradeable read lock</remarks> |
|
/// </summary> |
|
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; |
|
} |
|
} |
|
} |
|
|
|
/// <summary> |
|
/// Debug function to print the current document to the console. |
|
/// Includes formatted cursor position. |
|
/// </summary> |
|
[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); |
|
} |
|
|
|
/// <summary> |
|
/// Posts an update to the console |
|
/// <remarks>Safe to call on non-UI threads</remarks> |
|
/// </summary> |
|
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)); |
|
} |
|
|
|
/// <summary> |
|
/// Posts an update to the console. |
|
/// Helper for calling Post(ProcessOutput) with strings |
|
/// </summary> |
|
public void Post(string text) |
|
{ |
|
Post(new ProcessOutput { Text = text }); |
|
} |
|
|
|
/// <summary> |
|
/// Posts an update to the console. |
|
/// Equivalent to Post(text + Environment.NewLine) |
|
/// </summary> |
|
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); |
|
} |
|
}
|
|
|