src/NativeSession.cs

#nullable enable
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace QuietCliStack.V2 {
    public sealed class Record {
        public string Stream { get; init; } = "";
        public string Text { get; init; } = "";
        public long Sequence { get; set; }
        public bool Truncated { get; init; }
    }
    // Captures own their temporary directory. Read text only when requested; serialization never traverses this object.
    public sealed class Capture : IDisposable {
        public string DirectoryPath { get; }
        public string EncodingName { get; }
        public char StdoutDelimiter { get; }
        public char StderrDelimiter { get; }
        public long StdoutBytes, StderrBytes, LostRecords, ElapsedMs, CallbackMs, BackpressureMs;
        public int? ExitCode;
        public double? ProcessMs; public double SetupMs;
        public bool TimedOut, Cancelled, SourceComplete, CaptureComplete = true, Lossy, JournalComplete = true;
        public Record[] ReadRecords() {
            string path = Path.Combine(DirectoryPath, "records.bin");
            var records = new List<Record>();
            if (!File.Exists(path)) return records.ToArray();
            using var input = new BinaryReader(File.OpenRead(path), Encoding.UTF8);
            while (input.BaseStream.Position < input.BaseStream.Length) {
                records.Add(new Record { Sequence=input.ReadInt64(), Stream=input.ReadString(), Truncated=input.ReadBoolean(), Text=input.ReadString() });
            }
            return records.ToArray();
        }
                readonly Queue<string> stdoutTail = new(), stderrTail = new();
        public string[] ReadTail(string stream) { ValidateStream(stream); return (stream=="stdout" ? stdoutTail : stderrTail).ToArray(); }
        internal void RememberTail(string stream, string value) {
            var tail=stream=="stdout" ? stdoutTail : stderrTail;
            if (value.Length>1000) value=value.Substring(0,1000)+"…";
            tail.Enqueue(value);if(tail.Count>20)tail.Dequeue();
        }
        public string StartError = "";
        public string Stdout => ReadText("stdout");
        public string Stderr => ReadText("stderr");
        public Capture(string path, string encoding, char stdout, char stderr) {
            DirectoryPath = path; EncodingName = encoding; StdoutDelimiter = stdout; StderrDelimiter = stderr;
        }
        public string ReadText(string stream) {
            ValidateStream(stream);
            string path = Path.Combine(DirectoryPath, stream);
            return File.Exists(path) ? Encoding.GetEncoding(EncodingName).GetString(File.ReadAllBytes(path)) : "";
        }
        public string[] ReadLines(string stream, int from, int count) {
            ValidateStream(stream);
            if (from < 1 || count < 0) throw new ArgumentOutOfRangeException();
            var result = new List<string>();
            string path = Path.Combine(DirectoryPath, stream);
            if (!File.Exists(path)) return result.ToArray();
            using var reader = new StreamReader(path, Encoding.GetEncoding(EncodingName));
            int line = 0; string? value;
            while ((value = reader.ReadLine()) is not null && result.Count < count) {
                if (++line >= from) result.Add(value);
            }
            return result.ToArray();
        }
        static void ValidateStream(string stream) {
            if (stream != "stdout" && stream != "stderr") throw new ArgumentException("Unknown capture stream.");
        }
        public void Dispose() {
            try { if (Directory.Exists(DirectoryPath)) Directory.Delete(DirectoryPath, true); } catch (IOException) { } catch (UnauthorizedAccessException) { }
            GC.SuppressFinalize(this);
        }
        ~Capture() { Dispose(); }
    }
    // Adapted from QuietCli V1's concurrent readers. Domain callbacks always run on PowerShell's caller thread.
    public sealed class NativeSession : IDisposable {
        readonly BlockingCollection<Record> queue = new(16);
        readonly CancellationTokenSource readersCancel = new();
        readonly Process process = new();
        readonly Stopwatch watch = Stopwatch.StartNew();
        readonly Task completion;
        readonly Encoding encoding;
        readonly CancellationToken cancellation;
        readonly string? cancellationFile;
        readonly int recordLimit;
        readonly long captureLimit;
        readonly char stdoutDelimiter, stderrDelimiter;
        long sequence, delivered;
        BinaryWriter? journal;
        int incompleteCaptures;
        bool stdoutComplete, stderrComplete;
        public Capture Result { get; }
        public NativeSession(string executable, string[] arguments, string directory, IDictionary environment,
            string? input, string encodingName, int seconds, int maxRecord, long maxCapture,
            char stdoutSeparator, char stderrSeparator, CancellationToken token, string? signalFile) {
            encoding = Encoding.GetEncoding(encodingName);
            cancellation = token; cancellationFile = signalFile;
            recordLimit = maxRecord; captureLimit = maxCapture; stdoutDelimiter = stdoutSeparator; stderrDelimiter = stderrSeparator;
            string path = Path.Combine(Path.GetTempPath(), "quietcli-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(path);
            Result = new Capture(path, encodingName, stdoutSeparator, stderrSeparator);
            try { journal = new BinaryWriter(File.Create(Path.Combine(path, "records.bin")), Encoding.UTF8); } catch (IOException) { Result.JournalComplete=false; }
            process.StartInfo = new ProcessStartInfo(executable) {
                WorkingDirectory = directory, UseShellExecute = false, CreateNoWindow = true,
                RedirectStandardInput = input is not null, RedirectStandardOutput = true, RedirectStandardError = true
            };
            process.StartInfo.Environment["QUIETCLI_TRACKING"] = "0";
            process.StartInfo.Environment["QUIETCLI_RECALL"] = "0";
            foreach (DictionaryEntry entry in environment) {
                string key = (string)entry.Key;
                if (entry.Value is null) process.StartInfo.Environment.Remove(key);
                else process.StartInfo.Environment[key] = Convert.ToString(entry.Value);
            }
            foreach (string argument in arguments) process.StartInfo.ArgumentList.Add(argument);
            if (Cancelled()) {
                Result.Cancelled = true; queue.CompleteAdding(); completion = Task.CompletedTask; return;
            }
                        try {
                process.EnableRaisingEvents=true;
                double started=watch.Elapsed.TotalMilliseconds;
                process.Exited += (_, _) => Result.ProcessMs=watch.Elapsed.TotalMilliseconds-started;
                process.Start();Result.SetupMs=watch.Elapsed.TotalMilliseconds;
            }
            catch (Exception e) {
                Result.StartError = e.Message; Result.ElapsedMs = watch.ElapsedMilliseconds;
                queue.CompleteAdding(); completion = Task.CompletedTask; return;
            }
            completion = Task.Run(() => RunAsync(seconds, input));
        }
        bool Cancelled() => cancellation.IsCancellationRequested ||
            (!string.IsNullOrEmpty(cancellationFile) && File.Exists(cancellationFile));
        async Task Publish(string stream, StringBuilder text, long length) {
            Result.RememberTail(stream,text.ToString());
            bool oversized = length > recordLimit;
            if (oversized) Interlocked.Increment(ref Result.LostRecords);
            var record = new Record { Stream = stream, Text = oversized ? "" : text.ToString(),
                Sequence = Interlocked.Increment(ref sequence), Truncated = oversized };
            long started = Stopwatch.GetTimestamp();
            while (!queue.TryAdd(record)) await Task.Delay(1, readersCancel.Token).ConfigureAwait(false);
            Interlocked.Add(ref Result.BackpressureMs, (long)Stopwatch.GetElapsedTime(started).TotalMilliseconds);
        }
        async Task DrainAsync(Stream stream, string name, char delimiter) {
            var bytes = new byte[8192]; var chars = new char[encoding.GetMaxCharCount(bytes.Length)];
            var decoder = encoding.GetDecoder(); var strict = (Encoding)encoding.Clone();
            strict.DecoderFallback = DecoderFallback.ExceptionFallback;
            var verifier = strict.GetDecoder();
            var text = new StringBuilder(); long length = 0, stored = 0;
            FileStream? spool = null;
            try { spool = new FileStream(Path.Combine(Result.DirectoryPath, name), FileMode.CreateNew, FileAccess.Write, FileShare.Read); }
            catch (IOException) { Interlocked.Increment(ref incompleteCaptures); }
            try {
                int count;
                while ((count = await stream.ReadAsync(bytes.AsMemory(), readersCancel.Token).ConfigureAwait(false)) > 0) {
                    if (name == "stdout") Result.StdoutBytes += count; else Result.StderrBytes += count;
                    int keep = (int)Math.Min(count, Math.Max(0, captureLimit - stored));
                    if (keep < count) Interlocked.Increment(ref incompleteCaptures);
                    if (spool is not null && keep > 0) {
                        try { await spool.WriteAsync(bytes.AsMemory(0, keep), readersCancel.Token).ConfigureAwait(false); stored += keep; }
                        catch (IOException) { spool.Dispose(); spool = null; Interlocked.Increment(ref incompleteCaptures); }
                    }
                    try { verifier.GetCharCount(bytes, 0, count, false); }
                    catch (DecoderFallbackException) { Result.Lossy = true; verifier.Reset(); }
                    int produced = decoder.GetChars(bytes, 0, count, chars, 0, false);
                    for (int i = 0; i < produced; i++) {
                        if (chars[i] == delimiter) { await Publish(name, text, length).ConfigureAwait(false); text.Clear(); length = 0; }
                        else { length++; if (text.Length < recordLimit) text.Append(chars[i]); }
                    }
                }
                try { verifier.GetCharCount(Array.Empty<byte>(), 0, 0, true); }
                catch (DecoderFallbackException) { Result.Lossy = true; }
                int remaining = decoder.GetChars(Array.Empty<byte>(), 0, 0, chars, 0, true);
                for (int i = 0; i < remaining; i++) { length++; if (text.Length < recordLimit) text.Append(chars[i]); }
                if (length > 0) await Publish(name, text, length).ConfigureAwait(false);
                if (name == "stdout") stdoutComplete = true; else stderrComplete = true;
            } finally { spool?.Dispose(); }
        }
        async Task WriteInput(string? input) {
            if (input is null) return;
            byte[] bytes = encoding.GetBytes(input);
            try { await process.StandardInput.BaseStream.WriteAsync(bytes.AsMemory(), readersCancel.Token).ConfigureAwait(false); }
            finally { process.StandardInput.Close(); }
        }
        void Kill() {
            try { process.Kill(true); }
            catch (InvalidOperationException) { }
            catch (System.ComponentModel.Win32Exception) { }
        }
        async Task RunAsync(int seconds, string? input) {
            try {
                var finished = Task.WhenAll(process.WaitForExitAsync(readersCancel.Token),
                    DrainAsync(process.StandardOutput.BaseStream, "stdout", stdoutDelimiter),
                    DrainAsync(process.StandardError.BaseStream, "stderr", stderrDelimiter), WriteInput(input));
                while (!finished.IsCompleted) {
                    if (Cancelled() || watch.Elapsed.TotalSeconds >= seconds) {
                        Result.Cancelled = Cancelled(); Result.TimedOut = !Result.Cancelled; Kill();
                        if (await Task.WhenAny(finished, Task.Delay(2000)).ConfigureAwait(false) != finished) readersCancel.Cancel();
                        break;
                    }
                    await Task.WhenAny(finished, Task.Delay(25)).ConfigureAwait(false);
                }
                await finished.ConfigureAwait(false);
            } catch (OperationCanceledException) { }
              catch (Exception e) { Result.StartError = e.Message; Kill(); }
            finally {
                try { if (process.HasExited) Result.ExitCode = process.ExitCode; } catch (InvalidOperationException) { }
                Result.SourceComplete = stdoutComplete && stderrComplete && !Result.TimedOut && !Result.Cancelled && Result.StartError.Length == 0;
                Result.CaptureComplete = Result.SourceComplete && incompleteCaptures == 0;
                Result.ElapsedMs = watch.ElapsedMilliseconds;
                queue.CompleteAdding();
            }
        }
        public Record? Next() {
                        while (!queue.IsCompleted) if (queue.TryTake(out var record, 50)) {
                record.Sequence=++delivered;
                if (journal is not null && Result.JournalComplete) {
                    if (journal.BaseStream.Length + Encoding.UTF8.GetByteCount(record.Text) + 32 > captureLimit) Result.JournalComplete=false;
                    else try { journal.Write(record.Sequence);journal.Write(record.Stream);journal.Write(record.Truncated);journal.Write(record.Text); }
                    catch (IOException) { Result.JournalComplete=false; }
                }
                return record;
            }
            completion.GetAwaiter().GetResult(); return null;
        }
        public void Dispose() {
            if (!completion.IsCompleted) { readersCancel.Cancel(); Kill(); }
            completion.GetAwaiter().GetResult();
            journal?.Dispose(); process.Dispose(); readersCancel.Dispose(); queue.Dispose(); watch.Stop();
        }
    }
}