From f35880f0e685410bfa33a048a6aa771616da166f Mon Sep 17 00:00:00 2001 From: Scott Bilas Date: Tue, 13 Aug 2024 16:31:20 +0100 Subject: [PATCH] Stale WIP --- Stale.sln | 27 +++ src/Stale.Cli/Constants.cs | 9 + src/Stale.Cli/Context.cs | 86 +++++--- src/Stale.Cli/Playback.cs | 233 ++++++++++++++++++++ src/Stale.Cli/Screen.cs | 59 +++++ src/Stale.Cli/Screen_Out.cs | 91 ++++++++ src/Stale.Cli/StaleApp.cs | 229 +++++++++++++++++++- src/Stale.Cli/StaleCli.cs | 346 ++++++------------------------ src/Stale.Cli/StaleCli.docopt.txt | 16 +- src/Stale.Cli/StatusPane.cs | 39 ++++ 10 files changed, 813 insertions(+), 322 deletions(-) create mode 100644 src/Stale.Cli/Constants.cs create mode 100644 src/Stale.Cli/Playback.cs create mode 100644 src/Stale.Cli/Screen.cs create mode 100644 src/Stale.Cli/Screen_Out.cs create mode 100644 src/Stale.Cli/StatusPane.cs diff --git a/Stale.sln b/Stale.sln index 8c51962..4decca7 100644 --- a/Stale.sln +++ b/Stale.sln @@ -21,8 +21,19 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OkTools", "OkTools", "{3B7B Directory.Build.rsp = Directory.Build.rsp Directory.Packages.props = Directory.Packages.props global.json = global.json + targets\Exe.targets = targets\Exe.targets + targets\Library.targets = targets\Library.targets + targets\Tests.targets = targets\Tests.targets EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flog", "src\Flog\Flog.csproj", "{74302A65-1C99-4EEE-BCE5-E6871220C557}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Flog.Cli", "src\Flog.Cli\Flog.Cli.csproj", "{8B492F02-DFFE-49A0-91FA-2F7D6DAFC96B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Terminal", "src\Core.Terminal\Core.Terminal.csproj", "{97404C8D-5AF4-4421-952C-0942D453D692}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.Terminal-Tests", "src\Core.Terminal\Core.Terminal-Tests.csproj", "{85998317-0436-45AD-A4CB-278EC8055ADD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -53,5 +64,21 @@ Global {3D70F5C9-4AA8-4B3F-AB87-E23B8F6B7507}.Debug|Any CPU.Build.0 = Debug|Any CPU {3D70F5C9-4AA8-4B3F-AB87-E23B8F6B7507}.Release|Any CPU.ActiveCfg = Release|Any CPU {3D70F5C9-4AA8-4B3F-AB87-E23B8F6B7507}.Release|Any CPU.Build.0 = Release|Any CPU + {74302A65-1C99-4EEE-BCE5-E6871220C557}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74302A65-1C99-4EEE-BCE5-E6871220C557}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74302A65-1C99-4EEE-BCE5-E6871220C557}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74302A65-1C99-4EEE-BCE5-E6871220C557}.Release|Any CPU.Build.0 = Release|Any CPU + {8B492F02-DFFE-49A0-91FA-2F7D6DAFC96B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B492F02-DFFE-49A0-91FA-2F7D6DAFC96B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B492F02-DFFE-49A0-91FA-2F7D6DAFC96B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B492F02-DFFE-49A0-91FA-2F7D6DAFC96B}.Release|Any CPU.Build.0 = Release|Any CPU + {97404C8D-5AF4-4421-952C-0942D453D692}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {97404C8D-5AF4-4421-952C-0942D453D692}.Debug|Any CPU.Build.0 = Debug|Any CPU + {97404C8D-5AF4-4421-952C-0942D453D692}.Release|Any CPU.ActiveCfg = Release|Any CPU + {97404C8D-5AF4-4421-952C-0942D453D692}.Release|Any CPU.Build.0 = Release|Any CPU + {85998317-0436-45AD-A4CB-278EC8055ADD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85998317-0436-45AD-A4CB-278EC8055ADD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85998317-0436-45AD-A4CB-278EC8055ADD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85998317-0436-45AD-A4CB-278EC8055ADD}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/Stale.Cli/Constants.cs b/src/Stale.Cli/Constants.cs new file mode 100644 index 0000000..275973e --- /dev/null +++ b/src/Stale.Cli/Constants.cs @@ -0,0 +1,9 @@ +static class Constants +{ + public const string WrapText = "»"; + public const string WrapColor = "blue"; + + public const string TopStatusColor = "bold yellow on navyblue"; + public const string BottomStatusColor = "white on grey"; + public const string StderrColor = "white on darkred"; +} diff --git a/src/Stale.Cli/Context.cs b/src/Stale.Cli/Context.cs index ed0c83d..3978025 100644 --- a/src/Stale.Cli/Context.cs +++ b/src/Stale.Cli/Context.cs @@ -1,18 +1,20 @@ +using System.Diagnostics; using System.Runtime.CompilerServices; using Dumpify; using Spectre.Console; using Spectre.Console.Rendering; using Vezel.Cathode; +using InvalidOperationException = System.InvalidOperationException; #pragma warning disable CA1822 -enum StatType +enum PerfStatType { Init, Command, } -class Stat +class PerfStat { DateTime? _start, _stop; @@ -40,34 +42,75 @@ public void Stop() class Context : IDisposable { readonly CancellationTokenSource _cancelSource = new(); + readonly LongTasks _longTasks; public Context() { - _ansiConsole = new TerminalAnsiConsole(this); + _longTasks = new(_cancelSource.Token); } public void Dispose() { _cancelSource.Dispose(); + + var stillRunning = _longTasks.GetTasksSnapshot(); + if (stillRunning.Any()) + { + if (IsVerbose || Debugger.IsAttached) + { + ErrorLine("Long tasks were still running on exit:"); + for (var i = 0; i < stillRunning.Count; ++i) + { + var task = stillRunning[i]; + ErrorLine($" [{i+1}/{stillRunning.Count}] \"{task.Name}\""); + ErrorLine($" serial={task.Serial}, id={task.Task.Id}, status={task.Task.Status}"); + ErrorLine(task.Creation.ToString().Split('\n').Select(l => " " + l).StringJoin("\n")); + } + } + else + ErrorLine("Long tasks were still running on exit: " + stillRunning.Select(v => v.Name).StringJoin(", ")); + } } public CancellationToken CancelToken => _cancelSource.Token; public bool IsCancellationRequested => _cancelSource.IsCancellationRequested; public void Cancel() => _cancelSource.Cancel(); + public LongTasks LongTasks => _longTasks; + public StaleCliArguments Options = null!; - public bool IsVerbose; + public bool IsVerbose; // TODO: either make readonly for better JIT or force caller to check this before calling Verbose funcs public void VerboseLine(string text) { if (IsVerbose) - _ansiConsole.MarkupLineInterpolated($"[grey]{text}[/]"); + OutMarkupLineInterp($"[grey]{text}[/]"); } public void Verbose(string text) { if (IsVerbose) - _ansiConsole.MarkupInterpolated($"[grey]{text}[/]"); + OutMarkupInterp($"[grey]{text}[/]"); + } + + [Conditional("DEBUG")] + public void DebugLine(string text) + { + var frames = new StackTrace(1) + .GetFrames() + .Reverse() + .SelectWhere(frame => + { + var name = MiscUtils.ToNiceMethodName(frame.GetMethod()!); + + // "just my code" only + return (name, !name.StartsWith("System.")); + }); + + Debug.WriteLine( + $"{Environment.CurrentManagedThreadId,2}: "+ + $"#{Task.CurrentId ?? '-',2} | "+ + $"{MiscUtils.SequenceDuplicatesAsDots(frames).StringJoin(" ↗ ")} ⦚ {text}"); } public void OutLine() => @@ -87,16 +130,18 @@ public void Out(IRenderable renderable) => Terminal.Out(renderable.ToAnsi()); public void OutMarkupLine(string text) => - _ansiConsole.MarkupLine(text); + OutLine(new Markup(text).ToAnsi()); public void OutMarkup(string text) => - _ansiConsole.Markup(text); + Out(new Markup(text).ToAnsi()); public void OutMarkupLineInterp(FormattableString value) => - _ansiConsole.MarkupLineInterpolated(value); + OutLine(Markup.FromInterpolated(value).ToAnsi()); public void OutMarkupInterp(FormattableString value) => - _ansiConsole.MarkupInterpolated(value); + Out(Markup.FromInterpolated(value).ToAnsi()); public void ErrorLine(T value) => Terminal.ErrorLine(value); + public void ErrorLine() => + Terminal.ErrorLine(); public void Error(T value) => Terminal.Error(value); @@ -124,23 +169,7 @@ void OutDump(T value, string? label, ColorConfig colors) => value.Dump( output: s_dumpOutput, tableConfig: new() { ShowTableHeaders = false }); - public Stat this[StatType type] => _stats[(int)type]; - - class TerminalAnsiConsole(Context ctx) : IAnsiConsole - { - // we only need Write() - public Profile Profile => throw new InvalidOperationException(); - public IAnsiConsoleCursor Cursor => throw new InvalidOperationException(); - public IAnsiConsoleInput Input => throw new InvalidOperationException(); - public IExclusivityMode ExclusivityMode => throw new InvalidOperationException(); - public RenderPipeline Pipeline => throw new InvalidOperationException(); - public void Clear(bool home) => throw new InvalidOperationException(); - - public void Write(IRenderable renderable) - { - ctx.Out(renderable); - } - } + public PerfStat this[PerfStatType type] => _stats[(int)type]; class TerminalDumpOutput : IDumpOutput { @@ -152,8 +181,7 @@ class TerminalDumpOutput : IDumpOutput public TextWriter TextWriter { get; } = Terminal.StandardOut.TextWriter; } - readonly IAnsiConsole _ansiConsole; - readonly Stat[] _stats = EnumUtility.GetNames().Select(_ => new Stat()).ToArray(); + readonly PerfStat[] _stats = EnumUtility.GetNames().Select(_ => new PerfStat()).ToArray(); static readonly IDumpOutput s_dumpOutput = new TerminalDumpOutput(); static readonly ColorConfig k_verboseDumpColors = new(new DumpColor("#808080")); diff --git a/src/Stale.Cli/Playback.cs b/src/Stale.Cli/Playback.cs new file mode 100644 index 0000000..61e5803 --- /dev/null +++ b/src/Stale.Cli/Playback.cs @@ -0,0 +1,233 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Channels; +using Vezel.Cathode; +using Vezel.Cathode.Text.Control; + +readonly record struct LineCapture(bool IsStdErr, DateTime When, string Line) +{ + public override string ToString() => $"{(IsStdErr ? "⨯ " : "")}{Line.SimpleEscape()} ({When:g})"; +} + +readonly struct PlaybackOptions +{ + public PlaybackOptions(StaleCliArguments args) + { + Path = args.ArgRecorded!; + + if (args.OptSpeed != null) + { + var success = false; + + var m = Regex.Match(args.OptSpeed, @"(?[0-9.]+)x|(?\d+)ms"); + var (ratioGroup, delayGroup) = (m.Groups["ratio"], m.Groups["delay"]); + + if (ratioGroup.Success) + { + if (double.TryParse(ratioGroup.Value, out var value) && value > 0) + (Ratio, success) = (value, true); + } + else if (delayGroup.Success) + { + if (int.TryParse(delayGroup.Value, out var value) && value >= 0) + (Delay, success) = (value, true); + } + + if (!success) + throw new CliErrorException(CliExitCode.ErrorUsage, "Unable to parse speed argument; needs to be a ratio like 1.23x or a delay like 456ms"); + } + else + Ratio = 1; + } + + public readonly string Path; + + // null == unconfigured == play back at original speed + public readonly double? Ratio; + public readonly int? Delay; +} + +static class Playback +{ + const int k_jsonRecordingVersion = 2; + + public static async Task Record(Context ctx, string? recordedPath, string command, IReadOnlyList args) + { + var child = TerminalUtils.ShellExec(ctx, command, args); + + if (ctx.IsVerbose) + { + ctx.VerboseLine($">{child.Id} $ {command} {CliUtility.CommandLineArgsToString(args)}"); + ctx.VerboseLine($" (Recording to {ctx.Options.ArgRecorded ?? "stdout"}) "); + } + + var jsonStream = recordedPath != null + ? File.Create(recordedPath) + : Terminal.StandardOut.Stream; + + await using var writer = new StreamWriter(jsonStream); + writer.AutoFlush = true; + + await using var json = new Utf8JsonWriter(jsonStream, new JsonWriterOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); + + void JsonNewline() + { + json.Flush(); + writer.Write('\n'); + } + + json.WriteStartObject(); + + json.WriteNumber("version", k_jsonRecordingVersion); + JsonNewline(); + + json.WriteString("command", command); + JsonNewline(); + // ReSharper disable once MethodHasAsyncOverload + writer.Write(" "); + json.WriteStartArray("args"); + foreach (var arg in args) + json.WriteStringValue(arg); + json.WriteEndArray(); + JsonNewline(); + + var captureCount = 0; + var spinnerLast = 0; + const string spinner = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"; + DateTime statusLast = DateTime.Now; + var cb = new ControlBuilder(); + + void UpdateStatus(bool isStdErr) + { + // write a dot every hundred lines + if (captureCount % 100 == 0) + ctx.Out("."); + + // spinner every 100ms + var now = DateTime.Now; + if ((now-statusLast).TotalSeconds > 0.1) + { + statusLast = now; + cb.SaveCursorState(); + if (isStdErr) + cb.Print('⨯'); + else + { + cb.Print(spinner[spinnerLast++]); + spinnerLast %= spinner.Length; + } + cb.RestoreCursorState(); + ctx.Out(cb.Span); + cb.Clear(); + } + } + + DateTime? captureStart = null; + await foreach (var capture in child.Captures.ReadAllAsync(ctx.CancelToken)) + { + if (captureStart == null) + { + captureStart = capture.When; + json.WriteString("start", captureStart.Value); + JsonNewline(); + json.WriteStartArray("captures"); + JsonNewline(); + } + + JsonNewline(); + + json.WriteStartObject(); + if (capture.IsStdErr) + json.WriteBoolean("isStdErr", true); + json.WriteString("offset", $"{capture.When-captureStart:g}"); + json.WriteString("line", capture.Line); + json.WriteEndObject(); + + ++captureCount; + UpdateStatus(capture.IsStdErr); + } + + if (captureStart != null) + json.WriteEndArray(); + JsonNewline(); + JsonNewline(); + + json.WriteNumber("exitcode", await child.Exited); + + json.WriteEndObject(); + JsonNewline(); + + ctx.OutLine(" "); + + return CliExitCode.Success; + } + + public static StaleChildProcess StartPlayback(Context ctx, PlaybackOptions playbackOptions) + { + var captures = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); + + if (ctx.IsVerbose) + { + // ReSharper disable once CompareOfFloatsByEqualityOperator + if (playbackOptions.Ratio != 1.0) + ctx.VerboseLine($"Playing back '{ctx.Options.ArgRecorded}' at {playbackOptions.Ratio}x speed"); + else if (playbackOptions.Delay == 0) + ctx.VerboseLine($"Playing back '{ctx.Options.ArgRecorded}' at maximum speed"); + else if (playbackOptions.Delay != null) + ctx.VerboseLine($"Playing back '{ctx.Options.ArgRecorded}' at {playbackOptions.Delay}ms per line"); + else + ctx.VerboseLine($"Playing back '{ctx.Options.ArgRecorded}' at original speed"); + } + + using var reader = new StreamReader(File.OpenRead(playbackOptions.Path)); + + if (ctx.IsVerbose) + ctx.VerboseLine($"Deserializing json from '{playbackOptions.Path}'..."); + var records = JsonDocument.ParseAsync(reader.BaseStream, default, ctx.CancelToken).Result.RootElement; + + var version = records.GetProperty("version").GetInt32(); + if (version != k_jsonRecordingVersion) + throw new CliErrorException(CliExitCode.ErrorDataErr, $"Recorded file '{playbackOptions.Path}' is version {{version}}, expected {{jsonVersion}}"); + + var command = records.GetProperty("command").GetString()!; + var args = records.GetProperty("args").EnumerateArray().Select(arg => arg.GetString()!).ToList(); + var start = records.GetProperty("start").GetDateTime(); + var exitCode = records.GetProperty("exitcode").GetInt32(); + + async Task PlayLines(CancellationToken stopToken) + { + DateTime? last = null; + foreach (var capture in records.GetProperty("captures").EnumerateArray()) + { + if (stopToken.IsCancellationRequested) + return; + + var isStdErr = capture.TryGetProperty("isStdErr", out var isStdErrEl) && isStdErrEl.GetBoolean(); + var when = start + TimeSpan.Parse(capture.GetProperty("offset").GetString()!); + var line = capture.GetProperty("line").GetString()!; + + if (playbackOptions.Ratio != null) + { + if (last != null) + { + var delta = (when - last.Value).TotalMilliseconds; + if (delta != 0) + await Task.Delay((int)(delta / playbackOptions.Ratio!.Value), stopToken); + } + last = when; + } + else if (playbackOptions.Delay != 0) + await Task.Delay(playbackOptions.Delay!.Value, stopToken); + + await captures.Writer.WriteAsync(new(isStdErr, when, line), stopToken); + } + + captures.Writer.Complete(); + } + + // this is a standalone task rather than looping async, to keep it completely independent of stale processing + var longTask = ctx.LongTasks.Run($"Playback of '{playbackOptions.Path}'", PlayLines); + return new(command, args, captures.Reader, -1, longTask.Task.ContinueWith(_ => exitCode)); + } +} diff --git a/src/Stale.Cli/Screen.cs b/src/Stale.Cli/Screen.cs new file mode 100644 index 0000000..597a054 --- /dev/null +++ b/src/Stale.Cli/Screen.cs @@ -0,0 +1,59 @@ +using OkTools.Core.Terminal; +using Vezel.Cathode; +using Size = System.Drawing.Size; + +public enum ScreenUpdateStatus { Ok, TerminalSizeChanged } + +partial class Screen : IDisposable +{ + bool _disposed; + + readonly VirtualTerminal _terminal; + + Size _screenSize; + Size? _pendingScreenSize; + + public Screen(VirtualTerminal terminal) + { + _terminal = terminal; + _terminal.Resized += OnResized; + _pendingScreenSize = _terminal.Size; + + Control.SoftReset(); + Control.HideCursor(); + + Update(); + } + + public void Dispose() + { + ObjectDisposedException.ThrowIf(_disposed, this); + _disposed = true; + + // set terminal back to normal, we may have leftover state like margins or whatever + _cb.Clear(); + _cb.SoftReset(); + _terminal.Out(_cb); + + _terminal.Resized -= OnResized; + } + + public Size ScreenSize => _screenSize; + public int ScreenWidth => _screenSize.Width; + public int ScreenHeight => _screenSize.Height; + + public ScreenUpdateStatus Update() + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (_pendingScreenSize is null) + return ScreenUpdateStatus.Ok; + + _screenSize = _pendingScreenSize.Value; + _pendingScreenSize = null; + + return ScreenUpdateStatus.TerminalSizeChanged; + } + + void OnResized(Size newSize) => _pendingScreenSize = newSize; +} diff --git a/src/Stale.Cli/Screen_Out.cs b/src/Stale.Cli/Screen_Out.cs new file mode 100644 index 0000000..8e73bdb --- /dev/null +++ b/src/Stale.Cli/Screen_Out.cs @@ -0,0 +1,91 @@ +using System.Drawing; +using OkTools.Core.Terminal; +using Spectre.Console; +using Vezel.Cathode.Text.Control; + +static class ControlBuilderExtensions +{ + public static void PrintMarkupLine(this ControlBuilder @this, string text) => + @this.PrintLine(new Markup(text).ToAnsi()); + public static void PrintMarkup(this ControlBuilder @this, string text) => + @this.Print(new Markup(text).ToAnsi()); + public static void PrintMarkupLineInterp(this ControlBuilder @this, FormattableString value) => + @this.PrintLine(Markup.FromInterpolated(value).ToAnsi()); + public static void PrintMarkupInterp(this ControlBuilder @this, FormattableString value) => + @this.Print(Markup.FromInterpolated(value).ToAnsi()); +} + +partial class Screen +{ + readonly ControlBuilder _cb = new(); + + public ControlBuilder Control => _cb; + + public void FlushStdout() + { + if (_cb.Span.IsEmpty) + return; + + _terminal.Out(_cb.Span); + _cb.Clear(10*1024); + } + + public void SetCursorPos(int x, int y) + { + if (x < 0 || x >= _screenSize.Width) + throw new ArgumentOutOfRangeException(nameof(x), $"Out of range 0 <= {x} < {_screenSize.Width}"); + if (y < 0 || y >= _screenSize.Height) + throw new ArgumentOutOfRangeException(nameof(y), $"Out of range 0 <= {y} < {_screenSize.Height}"); + + _cb.MoveCursorTo(y, x); + } + +# pragma warning disable CA1822, RS0030 + public Point GetCursorPos() + { + // may be lingering move ops + FlushStdout(); + + // cathode does not yet support reading cursor position. see https://github.com/vezel-dev/cathode/discussions/16 + // i get the concern, but for this app i'm ensuring that i do cursor reading at well-defined times. + var (left, top) = Console.GetCursorPosition(); + return new Point(left, top); + } +# pragma warning restore CA1822, RS0030 + + public AutoSaveRestoreCursorState SaveRestoreCursorPos(bool showOnDispose = false, bool flushOnDispose = false) => + new(this, showOnDispose, flushOnDispose); + public AutoSaveRestoreCursorState SaveRestoreCursorPos(int x, int y, bool showOnDispose = false, bool flushOnDispose = false) + { + var saver = new AutoSaveRestoreCursorState(this, showOnDispose, flushOnDispose); + SetCursorPos(x, y); + return saver; + } + + public readonly struct AutoSaveRestoreCursorState : IDisposable + { + readonly Screen _screen; + readonly bool _showOnDispose, _flushOnDispose; + + public AutoSaveRestoreCursorState(Screen screen, bool showOnDispose = false, bool flushOnDispose = false) + { + _screen = screen; + _showOnDispose = showOnDispose; + _flushOnDispose = flushOnDispose; + + _screen.Control.SaveCursorState(); + _screen.Control.HideCursor(); + } + + public void Dispose() + { + _screen.Control.HideCursor(); + _screen.Control.RestoreCursorState(); + + if (_showOnDispose) + _screen.Control.ShowCursor(); + if (_flushOnDispose) + _screen.FlushStdout(); + } + } +} diff --git a/src/Stale.Cli/StaleApp.cs b/src/Stale.Cli/StaleApp.cs index aada28b..2ab1108 100644 --- a/src/Stale.Cli/StaleApp.cs +++ b/src/Stale.Cli/StaleApp.cs @@ -1,20 +1,235 @@ -using Vezel.Cathode; +using System.Threading.Channels; +using OkTools.Core.Terminal; +using Vezel.Cathode; + +readonly record struct StaleChildProcess( + string Command, IReadOnlyList Args, + ChannelReader Captures, + int Id, Task Exited); + +readonly struct StaleOptions +{ + public StaleOptions(StaleCliArguments args) + { + if (args.OptStart != null) + { + if (!int.TryParse(args.OptStart, out var start)) + throw new CliErrorException(CliExitCode.ErrorUsage, "START must be an integer"); + Start = start; + } + PauseEveryLine = args.OptPause; + } + + public readonly int? Start; + public readonly bool PauseEveryLine; +} class StaleApp : IDisposable { - public StaleApp(StaleCliArguments opts) + readonly Context _ctx; + readonly Screen _screen = new(Terminal.System); + readonly StatusPane _topStatusPane, _bottomStatusPane; + + // TODO: invalidate all panels on resize + + public StaleApp(Context ctx) { + _ctx = ctx; // unowned + _topStatusPane = new StatusPane(_screen, 0, Constants.TopStatusColor); + _bottomStatusPane = new StatusPane(_screen, 0, Constants.BottomStatusColor); } void IDisposable.Dispose() { + _screen.Dispose(); + if (_bottomStatusPane.Top != 0) + { + _screen.Control.Clear(); + _screen.Control.MoveCursorTo(_bottomStatusPane.Top, 0); + _screen.Control.PrintLine(); + _screen.FlushStdout(); + } } -// TEMP -#pragma warning disable CA1822 - public async Task Run() -#pragma warning restore CA1822 + public async Task Run(StaleOptions staleOptions, StaleChildProcess process) { - return await Task.FromResult(CliExitCode.Success); + string GetTopStatusText() => $">{process.Id} $ {process.Command} {CliUtility.CommandLineArgsToString(process.Args)}"; + string GetBottomStatusText() => $"...top={_topStatusPane.Top}, btm={_bottomStatusPane.Top}, cursor={_screen.GetCursorPos()}"; + + //// setup + + Terminal.EnableRawMode(); + + // fire & forget, because ReadKeysAsync routes them into the channel and the `await foreach` will pick that up + var events = Channel.CreateUnbounded(); + _ctx.LongTasks.Run("Stdin Reader", cancel => AnsiInput.ReadKeysAsync(Terminal.TerminalIn, events.Writer, e => + { + if (e is { Modifiers: ConsoleModifiers.Control, Key: ConsoleKey.C }) + _ctx.Cancel(); + return e; + }, cancel)); + + if (staleOptions.Start != null) + { + _screen.Control.ClearScreen(); + _screen.SetCursorPos(0, staleOptions.Start.Value); + _screen.FlushStdout(); + } + + _topStatusPane.Print(GetTopStatusText()); + _screen.Control.PrintLine(); + _screen.Control.PrintLine("‡"); + _bottomStatusPane.Print(GetBottomStatusText()); + _screen.Control.MoveCursorLineStart(); + _screen.Control.MoveCursorUp(); + + var cursorPos = _screen.GetCursorPos(); + _topStatusPane.Top = cursorPos.Y-1; + _bottomStatusPane.Top = cursorPos.Y+1; + + //// utility + + var pauseSkip = _ctx.Options.OptPause ? 0 : -1; + + void Pause() + { + if (pauseSkip != 0) + { + if (pauseSkip > 0) + --pauseSkip; + return; + } + + for (;;) + { + var key = new byte[1]; + Terminal.Read(key); // TODO: copy pasta rotate InputParser from Flog + + switch ((char)key[0]) + { + case 'q' : _ctx.Cancel(); break; + case '\r': pauseSkip = 0; break; + case ' ' : pauseSkip = 9; break; + default : continue; + } + + break; + } + } + + void OutLine(bool isStdErr, ReadOnlySpan span) + { + Pause(); + + // check for ctrl-c with peek (need to buffer stdin on my own) + + if (span.Length > _screen.ScreenWidth) + throw new ArgumentException($"Span too wide ({span.Length}) for screen width ({_screen.ScreenWidth})"); + + if (_ctx.IsCancellationRequested) + return; + + // pre-scroll + if (_bottomStatusPane.Top < _screen.ScreenHeight-1) + { + { + using var _ = _screen.SaveRestoreCursorPos(flushOnDispose: true); + _screen.Control.SetScrollMargin(_bottomStatusPane.Top-1, _screen.ScreenHeight-1); + _screen.Control.MoveBufferDown(); + _screen.Control.ResetScrollMargin(); + } + + if (span.Length == 0) + { + _screen.Control.PrintLine(" "); // overwrite dagger + } + else if (isStdErr) + { + _screen.Control.MoveCursorLineStart(); + _screen.Control.PrintMarkupLineInterp($"[default on darkred]{span.ToString()}[/]"); + } + else + { + _screen.Control.PrintLine(span); + } + ++cursorPos.Y; + + ++_bottomStatusPane.Top; + _bottomStatusPane.Update(GetBottomStatusText()); + } +/* else if (top > 0) + { + cb.SetScrollMargin(0, btm - btmStatusHeight); + cb.MoveBufferUp(1); + --top; + } + else + { + cb.SetScrollMargin(topStatusHeight, btm-btmStatusHeight); + cb.MoveBufferUp(1); + }*/ + +// cb.SetScrollMargin(0, _screen.ScreenHeight-1); +// cb.MoveCursorTo(btm-btmStatusHeight, 0); +// FlushCb(); + + // TODO: should i be using async versions of these Out funcs..? +// if (isStdErr) +// _ctx.MarkupLine($"[{stderrColor}]{span.ToString().PadRight(_screen.ScreenWidth).EscapeMarkup()}[/]"); +// else +// _ctx.Line(span); + +// UpdateBtmStatus(); + + _screen.FlushStdout(); + } + + //// main loop + + await foreach (var capture in process.Captures.ReadAllAsync(_ctx.CancelToken)) + { + // TODO: support incremental printing per line + // TODO: support batching output. shouldn't redraw status except every x msec. + + // need this local function to work around dotnet await+span limitation + void Process() + { + var span = capture.Line.AsSpan(); + + while (span.Length > _screen.ScreenWidth && !_ctx.IsCancellationRequested) + { + OutLine(capture.IsStdErr, span[.._screen.ScreenWidth]); + span = span[_screen.ScreenWidth..]; + } + + if (span.Length > 0) + OutLine(capture.IsStdErr, span); + } + + if (_ctx.IsCancellationRequested) + break; + + if (capture.Line.IsNullOrWhiteSpace()) + { + // TODO: consider a flag to skip these or a sequence of n of these (to compress vertical space a bit where we have a spacey tool). + // could also consider compressing them after seeing a few lines worth (with a little margin marker) + + OutLine(capture.IsStdErr, default); + } + else + Process(); + } + + //// cleanup + + _ctx.OutLine(); + + if (_ctx.IsCancellationRequested) + _ctx.OutMarkupLine("[yellow]Aborted![/]"); + + if (process.Exited.IsCompleted) + _ctx.OutLine($"{process.Command} exited normally with exit code {process.Exited.Result}"); + + return CliExitCode.Success; } } diff --git a/src/Stale.Cli/StaleCli.cs b/src/Stale.Cli/StaleCli.cs index fb21936..995beaa 100644 --- a/src/Stale.Cli/StaleCli.cs +++ b/src/Stale.Cli/StaleCli.cs @@ -1,24 +1,22 @@ -using System.Diagnostics; -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Threading.Channels; +using System.Diagnostics; +using System.Globalization; using DocoptNet; using Spectre.Console; using Vezel.Cathode; -using Vezel.Cathode.Processes; const string programVersion = "0.1"; -const int jsonVersion = 1; -var programStart = DateTime.Now; +CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture; +CultureInfo.DefaultThreadCurrentUICulture = CultureInfo.InvariantCulture; + using var ctx = new Context(); -ctx[StatType.Init].Start(); +ctx[PerfStatType.Init].Start(); var logStats = false; // ReSharper disable AccessToDisposedClosure // ^ talking about ctx here, it's ok, it outlives everything +/* // doesn't do anything in raw mode; unsure we need it Terminal.Signaled += signalContext => { if (signalContext.Signal != TerminalSignal.Interrupt) @@ -30,9 +28,25 @@ ctx.Cancel(); }; +*/ + +/* +// TODO: figure out why i need this event to catch a ctrl-c from --pause (is it because pause uses Console.ReadKey?) +#pragma warning disable RS0030 +Console.CancelKeyPress += (_, _) => +{ + // TODO: pass through ctrl-c and attempt to let the child process exit gracefully + + ctx.Cancel(); +}; +#pragma warning restore RS0030 +*/ try { + if (!Terminal.StandardIn.IsInteractive) + throw new CliErrorException(CliExitCode.ErrorUsage, "This app requires an interactive terminal"); + { var isVerbose = false; NPath? useCwd = null; @@ -50,6 +64,13 @@ throw new DocoptInputErrorException("--cwd needs a path"); useCwd = args[i]; } + else if (args[i] == "--dbgpause") + { + ctx.Out("Waiting for debugger to attach..."); + while (!Debugger.IsAttached) + Thread.Sleep(100); + ctx.OutLine("attached!"); + } else { args = args[i..]; @@ -90,82 +111,59 @@ ctx.VerboseDump(ctx.Options); if (ctx.Options.CmdRecord) - return Run(() => Record(ctx.Options.ArgRecorded, ctx.Options.ArgCommand!, [..ctx.Options.ArgArg])); + return await Exec(async () => (int)await Playback.Record(ctx, ctx.Options.ArgRecorded, ctx.Options.ArgCommand!, [..ctx.Options.ArgArg])); - if (ctx.Options.CmdPlay) + if (ctx.Options is { CmdPlay: true, OptPassthru: true }) { - double? ratio = null; - int? delay = null; - - if (ctx.Options.OptSpeed != null) + return await Exec(async () => { - var success = false; - - var m = Regex.Match(ctx.Options.OptSpeed, @"(?[0-9.]+)x|(?\d+)ms"); - var ratioGroup = m.Groups["ratio"]; - var delayGroup = m.Groups["delay"]; - - if (ratioGroup.Success) - { - if (double.TryParse(ratioGroup.Value, out var value) && value > 0) - { - ratio = value; - success = true; - } - } - else if (delayGroup.Success) - { - if (int.TryParse(delayGroup.Value, out var value) && value >= 0) - { - delay = value; - success = true; - } - } - - if (!success) - { - throw new CliErrorException( - CliExitCode.ErrorUsage, - "Unable to parse speed argument; needs to be a ratio like 1.23x or a delay like 456ms"); - } - } + var child = Playback.StartPlayback(ctx, new PlaybackOptions(ctx.Options)); + await foreach (var capture in child.Captures.ReadAllAsync(ctx.CancelToken)) + await (capture.IsStdErr ? Terminal.StandardError : Terminal.StandardOut).WriteLineAsync(capture.Line, ctx.CancelToken); + return await child.Exited; + }); + } - if (ctx.IsVerbose) - { - // ReSharper disable once CompareOfFloatsByEqualityOperator - if (ratio != null && ratio != 1.0) - ctx.VerboseLine($"Playing back '{ctx.Options.ArgRecorded}' at {ratio}x speed"); - else if (delay == 0) - ctx.VerboseLine($"Playing back '{ctx.Options.ArgRecorded}' at maximum speed"); - else if (delay != null) - ctx.VerboseLine($"Playing back '{ctx.Options.ArgRecorded}' at {delay}ms per line"); - else - ctx.VerboseLine($"Playing back '{ctx.Options.ArgRecorded}' at original speed"); - } + using var app = new StaleApp(ctx); - return Run(() => Play(ctx.Options.ArgRecorded!, ratio, delay, ctx.CancelToken)); + StaleChildProcess child; + if (ctx.Options.CmdPlay) + child = Playback.StartPlayback(ctx, new PlaybackOptions(ctx.Options)); + else + { + var command = ctx.Options.ArgCommand!; + IReadOnlyList childArgs = [..ctx.Options.ArgArg]; + child = TerminalUtils.ShellExec(ctx, command, childArgs); } - return Run(() => Main(ctx.Options.ArgCommand!, [..ctx.Options.ArgArg])); + return await Exec(async () => (int)await app.Run(new StaleOptions(ctx.Options), child)); } catch (CliErrorException x) { ctx.ErrorLine(x.Message); + ctx.LongTasks.AbortAll(); return (int)x.Code; } +catch (OperationCanceledException) +{ + ctx.ErrorLine("Aborted"); + ctx.LongTasks.AbortAll(); + return (int)UnixSignal.KeyboardInterrupt.AsCliExitCode(); +} catch (Exception x) { ctx.Error(x); + ctx.LongTasks.AbortAll(); return (int)CliExitCode.ErrorSoftware; } -int Run(Func> task) +async Task Exec(Func> task) { - ctx[StatType.Init].Stop(); + ctx[PerfStatType.Init].Stop(); - ctx[StatType.Command].Start(); - var result = task().Result; - ctx[StatType.Command].Stop(); + ctx[PerfStatType.Command].Start(); + var result = await task(); + ctx[PerfStatType.Command].Stop(); if (logStats) { @@ -176,7 +174,7 @@ int Run(Func> task) table.AddColumn("Stop"); table.AddColumn("Elapsed"); - foreach (var statType in EnumUtility.GetValues()) + foreach (var statType in EnumUtility.GetValues()) { var stat = ctx[statType]; table.AddRow( @@ -186,8 +184,8 @@ int Run(Func> task) stat.Elapsed.TotalSeconds.ToString("F3")); } - var totalStart = ctx[EnumUtility.GetValues().First()].StartTime; - var totalStop = ctx[EnumUtility.GetValues().Last()].StopTime; + var totalStart = ctx[EnumUtility.GetValues().First()].StartTime; + var totalStop = ctx[EnumUtility.GetValues().Last()].StopTime; table.AddRow( "Total", totalStart.ToString("hh:mm:ss.fff"), @@ -195,219 +193,7 @@ int Run(Func> task) (totalStop - totalStart).TotalSeconds.ToString("F3")); ctx.Out(table); - -/* var operationElapsed = DateTime.Now - operationStart; - var programElapsed = DateTime.Now - programStart; - ctx.OutLine( - $"Finished in {operationElapsed.TotalSeconds:F3}s (total {programElapsed.TotalSeconds:F3}s) "+ - $"with exit code {result} ({(int)result})");*/ } - return (int)result; -} - -async Task Main(string command, IReadOnlyList args) -{ - var (process, captures) = ShellExec(command, args); - - var dims = Terminal.Size; - Terminal.Resized += size => dims = size; // TODO: also do a re-layout - - const string statusColor = "bold yellow on navyblue"; - const string stderrColor = "white on darkred"; - - var status = $">{process.Id} $ {command} {CliUtility.CommandLineArgsToString(args)}"; - ctx.OutMarkupLine( - status.Length <= dims.Width - ? $"[{statusColor}]{status.PadRight(dims.Width).EscapeMarkup()}[/]" - : $"[{statusColor}]{status[..(dims.Width-1)].EscapeMarkup()}[/][blue]»[/]"); - - await foreach (var capture in captures.ReadAllAsync(ctx.CancelToken)) - { - void Process() - { - var span = capture.Line.AsSpan(); - - void Out(ReadOnlySpan span) - { - // TODO: should i be using async versions of these Out funcs..? - - if (capture.IsStdErr) - ctx.OutMarkupLine($"[{stderrColor}]{span.ToString().PadRight(dims.Width).EscapeMarkup()}[/]"); - else - ctx.OutLine(span); - } - - // this would be a blank line, not "no line" - if (span.Length == 0) - { - Out(span); - return; - } - - while (span.Length > dims.Width) - { - Out(span[..dims.Width]); - span = span[dims.Width..]; - } - - if (span.Length > 0) - Out(span); - } - - Process(); - } - - return CliExitCode.Success; -} - -async Task Record(string? recordedPath, string command, IReadOnlyList args) -{ - var (process, captures) = ShellExec(command, args); - - if (ctx.IsVerbose) - { - ctx.VerboseLine($">{process.Id} $ {command} {CliUtility.CommandLineArgsToString(args)}"); - ctx.VerboseLine($" (Recording to ${ctx.Options.ArgRecorded ?? "stdout"})"); - } - - var jsonStream = recordedPath != null - ? File.Create(recordedPath) - : Terminal.StandardOut.Stream; - - await using var writer = new StreamWriter(jsonStream); - writer.AutoFlush = true; - - await using var json = new Utf8JsonWriter(jsonStream, new JsonWriterOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }); - json.WriteStartObject(); - json.WriteNumber("version", jsonVersion); - json.WriteStartArray("captures"); - json.Flush(); - writer.Write('\n'); - - await foreach (var capture in captures.ReadAllAsync(ctx.CancelToken)) - { - writer.Write('\n'); - - json.WriteStartObject(); - if (capture.IsStdErr) - json.WriteBoolean("isStdErr", true); - json.WriteString("when", capture.When); - json.WriteString("line", capture.Line); - json.WriteEndObject(); - json.Flush(); - } - - json.WriteEndArray(); - json.Flush(); - writer.Write("\n\n"); - - json.WriteNumber("exitcode", process.Completion.Result); - - json.WriteEndObject(); - json.Flush(); - - writer.Write('\n'); - - return CliExitCode.Success; -} - -async Task Play(string recordedPath, double? ratio, int? delay, CancellationToken cancel) -{ - using var reader = new StreamReader(File.OpenRead(recordedPath)); - - var json = await JsonDocument.ParseAsync(reader.BaseStream, default, cancel); - - var version = json.RootElement.GetProperty("version").GetInt32(); - if (version != jsonVersion) - throw new CliErrorException(CliExitCode.ErrorDataErr, $"Recorded file '{recordedPath}' is version {{version}}, expected {{jsonVersion}}"); - - var captures = json.RootElement.GetProperty("captures"); - var exitCode = json.RootElement.GetProperty("exitcode").GetInt32(); - - if (ratio == null && delay == null) - ratio = 1; - - DateTime? last = null; - - foreach (var capture in captures.EnumerateArray()) - { - var isStdErr = capture.TryGetProperty("isStdErr", out var isStdErrEl) && isStdErrEl.GetBoolean(); - var when = capture.GetProperty("when").GetDateTime(); - var line = capture.GetProperty("line").GetString(); - - if (ratio != null) - { - if (last != null) - { - var delta = (when - last.Value).TotalMilliseconds; - await Task.Delay((int)(delta / ratio.Value), cancel); // returns CompletedTask if delay (int ms) is 0 - } - last = when; - } - else if (delay != 0) - await Task.Delay(delay!.Value, cancel); - - if (isStdErr) - Terminal.StandardError.WriteLine(line); - else - Terminal.StandardOut.WriteLine(line); - } - - return (CliExitCode)exitCode; -} - -(ChildProcess process, ChannelReader reader) ShellExec(NPath command, IReadOnlyList args) -{ - if (ShellExecUtility.ConfigureProcessExitToAlsoKillChildProcesses() && ctx.IsVerbose) - ctx.VerboseLine("Configuring OS to kill child processes if the current process exits"); - - // TODO: consider checking if it's a console app - // (see IsWindowsApplication at PowerShell\src\System.Management.Automation\engine\NativeCommandProcessor.cs:1199) - - var (commandPath, extraArgs) = ShellExecUtility.ResolveShellCommand(command); - if (extraArgs.Any()) - args = [..extraArgs, ..args]; - - var process = new ChildProcessBuilder() - .WithFileName(commandPath) - .WithArguments(args) - .WithRedirections(false, true, true) - .WithCreateWindow(false) - .WithWindowStyle(ProcessWindowStyle.Hidden) - .WithCancellationToken(ctx.CancelToken) - .WithThrowOnError(false) - .Run(); - - var captures = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true }); - - var open = 0; - - async void Write(TextReader reader, bool isStdErr) - { - Interlocked.Increment(ref open); - - while (!ctx.IsCancellationRequested) - { - var line = await reader.ReadLineAsync(ctx.CancelToken); - if (line == null) - { - if (Interlocked.Decrement(ref open) == 0) - captures.Writer.Complete(); - break; - } - - await captures.Writer.WriteAsync(new Capture(isStdErr, DateTime.Now, line), ctx.CancelToken); - } - } - - _ = Task.Run(() => Write(process.StandardOut.TextReader, false), ctx.CancelToken); - _ = Task.Run(() => Write(process.StandardError.TextReader, true), ctx.CancelToken); - - return (process, captures.Reader); -} - -readonly record struct Capture(bool IsStdErr, DateTime When, string Line) -{ - public override string ToString() => $"{(IsStdErr ? "! " : "")}{Line.SimpleEscape()} ({When:g})"; + return result; } diff --git a/src/Stale.Cli/StaleCli.docopt.txt b/src/Stale.Cli/StaleCli.docopt.txt index 0d5b678..b7cc686 100644 --- a/src/Stale.Cli/StaleCli.docopt.txt +++ b/src/Stale.Cli/StaleCli.docopt.txt @@ -1,9 +1,9 @@ {0}, the streaming output processor v{1} Usage: - {0} -- COMMAND [ARG...] + {0} [--pause] [--start START] -- COMMAND [ARG...] {0} record [RECORDED] -- COMMAND [ARG...] - {0} play RECORDED [--speed SPEED] + {0} play RECORDED [--speed SPEED] [--pause] [--start START] [--passthru] {0} -h | --help {0} --version @@ -15,14 +15,18 @@ Commands: play Deserialize output from RECORDED and play back as if it was running live. Useful for deterministic testing. Options: + --pause Wait for a keystroke before every line printed + --start START Start at the START vertical position (if set, will also clear the screen) --speed SPEED Set playback speed. Can either be: - A time like "10ms" (10 milliseconds delay per line) in which case it ignores the original capture timings and plays back at a fixed rate per line. Set to 0ms to play at maximum speed. - A ratio like "3.2x" where it will play back the original timings but sped up/down by the multiplier (> 1 is faster, < 1 is slower). Set to 1x to attempt to play back at the original speed. + --passthru Just pass through the captures to the terminal, don't run it by Stale main processing. Special: - --verbose Log a lot of detail about what is happening. This can be applied to any command line as long as it appears - before anything else. - --stats Log cheap but useful stats about the run after command completion. - --cwd Use this as the working dir for {0}. Defaults to shell's working dir. + --verbose Log a lot of detail about what is happening. This can be applied to any command line as long as it appears + before anything else. + --stats Log cheap but useful stats about the run after command completion. + --cwd Use this as the working dir for {0}. Defaults to shell's working dir. + --dbgpause Pause before running the command to allow attaching a debugger. diff --git a/src/Stale.Cli/StatusPane.cs b/src/Stale.Cli/StatusPane.cs new file mode 100644 index 0000000..1c083ee --- /dev/null +++ b/src/Stale.Cli/StatusPane.cs @@ -0,0 +1,39 @@ +using Spectre.Console; + +class StatusPane +{ + readonly Screen _screen; + readonly string _color; + string _text = ""; + + // FUTURE: get rid of 'color' and do a segment config approach like starship etc. + public StatusPane(Screen screen, int top, string color) + { + _screen = screen; + _color = color; + Top = top; + } + + public int Top; + + public void Invalidate() => _text = ""; + + public void Print(string text) + { + _text = text; + + _screen.Control.PrintMarkup(text.Length <= _screen.ScreenWidth + ? $"[{_color}]{text.PadRight(_screen.ScreenWidth).EscapeMarkup()}[/]" + : $"[{_color}]{text[..(_screen.ScreenWidth-Constants.WrapText.Length)].EscapeMarkup()}[/]"+ + $"[{Constants.WrapColor}]{Constants.WrapText}[/]"); + } + + public void Update(string text) + { + if (_text == text) + return; + + using var _ = _screen.SaveRestoreCursorPos(0, Top, flushOnDispose: true); + Print(text); + } +}