From c01278c0f43902682647dffe0fdf884da0bc3cfb Mon Sep 17 00:00:00 2001 From: Martijn Laarman Date: Mon, 15 Jan 2024 22:24:01 +0100 Subject: [PATCH] Add static support for long running processes (#13) * Add static support for long running programs with manual started confirmations * add fsharp bindings --- examples/ScratchPad.Fs/Program.fs | 14 ++++- src/Proc.Fs/Bindings.fs | 39 +++++++++++- src/Proc.Fs/README.md | 21 +++++++ src/Proc/ExecArguments.cs | 3 - src/Proc/LongRunningArguments.cs | 24 ++++++++ src/Proc/ObservableProcess.cs | 7 ++- src/Proc/ObservableProcessBase.cs | 45 +++++++------- src/Proc/Proc.Start.cs | 55 ++++++----------- src/Proc/Proc.StartLongRunning.cs | 86 +++++++++++++++++++++++++++ src/Proc/Proc.StartRedirected.cs | 23 ++++--- src/Proc/ProcessArgumentsBase.cs | 6 +- src/Proc/StartArguments.cs | 14 +++++ tests/Proc.Tests.Binary/Program.cs | 41 ++++++++++++- tests/Proc.Tests/LongRunningTests.cs | 73 +++++++++++++++++++++++ tests/Proc.Tests/ProcTestCases.cs | 3 +- tests/Proc.Tests/ReadLineTestCases.cs | 2 +- tests/Proc.Tests/TestsBase.cs | 8 ++- 17 files changed, 384 insertions(+), 80 deletions(-) create mode 100644 src/Proc/LongRunningArguments.cs create mode 100644 src/Proc/Proc.StartLongRunning.cs create mode 100644 tests/Proc.Tests/LongRunningTests.cs diff --git a/examples/ScratchPad.Fs/Program.fs b/examples/ScratchPad.Fs/Program.fs index f1556cf..1ceab06 100644 --- a/examples/ScratchPad.Fs/Program.fs +++ b/examples/ScratchPad.Fs/Program.fs @@ -1,13 +1,14 @@ open System open Proc.Fs +(* let _ = shell { exec "dotnet" "--version" exec "uname" } - exec { run "dotnet" "--help"} + exec { binary "dotnet" arguments "--help" @@ -38,7 +39,7 @@ let dotnetVersion = exec { filter (fun l -> l.Line.Contains "clean") } -printfn "Found lines %i" dotnetVersion.Length +printfn $"Found lines %i{dotnetVersion.Length}" let dotnetOptions = exec { binary "dotnet" } @@ -55,5 +56,14 @@ let _ = shell { exec "dotnet" args } let statusCode = exec { exit_code_of "dotnet" "--help"} exec { run "dotnet" "run" "--project" "examples/ScratchPad.Fs.ArgumentPrinter" "--" "With Space" } +*) + +let runningProcess = exec { + binary "dotnet" + arguments "run" "--project" "tests/Proc.Tests.Binary" "--" "TrulyLongRunning" + //wait_until (fun l -> l.Line = "Started!") + wait_until_and_disconnect (fun l -> l.Line = "Started!") +} + printfn "That's all folks!" diff --git a/src/Proc.Fs/Bindings.fs b/src/Proc.Fs/Bindings.fs index 37420a8..f6a3fef 100644 --- a/src/Proc.Fs/Bindings.fs +++ b/src/Proc.Fs/Bindings.fs @@ -15,6 +15,9 @@ type ExecOptions = { Timeout: TimeSpan ValidExitCodeClassifier: (int -> bool) option + StartedConfirmationHandler: (LineOut -> bool) option + StopBufferingAfterStarted: bool option + NoWrapInThread: bool option SendControlCFirst: bool option WaitForStreamReadersTimeout: TimeSpan option @@ -26,7 +29,8 @@ with LineOutFilter = None; WorkingDirectory = None; Environment = None Timeout = TimeSpan(0, 0, 0, 0, -1) ValidExitCodeClassifier = None; - NoWrapInThread = None; SendControlCFirst = None; WaitForStreamReadersTimeout = None; + NoWrapInThread = None; SendControlCFirst = None; WaitForStreamReadersTimeout = None + StartedConfirmationHandler = None; StopBufferingAfterStarted = None } let private startArgs (opts: ExecOptions) = @@ -48,6 +52,21 @@ let private execArgs (opts: ExecOptions) = opts.ValidExitCodeClassifier |> Option.iter(fun f -> execArguments.ValidExitCodeClassifier <- f) execArguments +let private longRunningArguments (opts: ExecOptions) = + let args = opts.Arguments |> Option.defaultValue [] + let longRunningArguments = LongRunningArguments(opts.Binary, args) + opts.LineOutFilter |> Option.iter(fun f -> longRunningArguments.LineOutFilter <- f) + opts.Environment |> Option.iter(fun e -> longRunningArguments.Environment <- e) + opts.WorkingDirectory |> Option.iter(fun d -> longRunningArguments.WorkingDirectory <- d) + opts.NoWrapInThread |> Option.iter(fun b -> longRunningArguments.NoWrapInThread <- b) + opts.SendControlCFirst |> Option.iter(fun b -> longRunningArguments.SendControlCFirst <- b) + opts.WaitForStreamReadersTimeout |> Option.iter(fun t -> longRunningArguments.WaitForStreamReadersTimeout <- t) + + opts.StartedConfirmationHandler |> Option.iter(fun t -> longRunningArguments.StartedConfirmationHandler <- t) + opts.StopBufferingAfterStarted |> Option.iter(fun t -> longRunningArguments.StopBufferingAfterStarted <- t) + + longRunningArguments + type ShellBuilder() = @@ -251,6 +270,24 @@ type ExecBuilder() = let startArgs = startArgs opts Proc.Start(startArgs, opts.Timeout) + [] + member this.WaitUntil(opts, startedConfirmation: LineOut -> bool) = + let opts = { opts with StartedConfirmationHandler = Some startedConfirmation } + let longRunningArguments = longRunningArguments opts + Proc.StartLongRunning(longRunningArguments, opts.Timeout) + + [] + member this.WaitUntilQuietAfter(opts, startedConfirmation: LineOut -> bool) = + let opts = + { + opts with + StartedConfirmationHandler = Some startedConfirmation + StopBufferingAfterStarted = Some true + } + let longRunningArguments = longRunningArguments opts + Proc.StartLongRunning(longRunningArguments, opts.Timeout) + + let exec = ExecBuilder() diff --git a/src/Proc.Fs/README.md b/src/Proc.Fs/README.md index 2a7d1ad..b9ae7f9 100644 --- a/src/Proc.Fs/README.md +++ b/src/Proc.Fs/README.md @@ -103,3 +103,24 @@ let helpOutput = exec { ``` returns the exit code and the full console output. + +```fsharp + +let process = exec { + binary "dotnet" + arguments "--help" + wait_until (fun l -> l.Line.Contains "clean") +} +``` + +returns an already running process but only after it confirms a line was printed +```fsharp + +let process = exec { + binary "dotnet" + arguments "--help" + wait_until_and_disconnect (fun l -> l.Line.Contains "clean") +} +``` + +returns an already running process but only after it confirms a line was printed. This version will stop the yielding standard/out lines which may utilize memory consumption which is no longer needed. diff --git a/src/Proc/ExecArguments.cs b/src/Proc/ExecArguments.cs index 2e5d87a..3efe93c 100644 --- a/src/Proc/ExecArguments.cs +++ b/src/Proc/ExecArguments.cs @@ -10,9 +10,6 @@ public ExecArguments(string binary, IEnumerable args) : base(binary, arg public ExecArguments(string binary, params string[] args) : base(binary, args) { } - /// Force arguments and the current working director NOT to be part of the exception message - public bool OnlyPrintBinaryInExceptionMessage { get; set; } - public Func ValidExitCodeClassifier { get => _validExitCodeClassifier ?? (c => c == 0); diff --git a/src/Proc/LongRunningArguments.cs b/src/Proc/LongRunningArguments.cs new file mode 100644 index 0000000..a4fee95 --- /dev/null +++ b/src/Proc/LongRunningArguments.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using ProcNet.Std; + +namespace ProcNet; + +public class LongRunningArguments : StartArguments +{ + public LongRunningArguments(string binary, IEnumerable args) : base(binary, args) { } + + public LongRunningArguments(string binary, params string[] args) : base(binary, args) { } + + /// + /// A handler that will delay return of the process until startup is confirmed over + /// standard out/error. + /// + public Func StartedConfirmationHandler { get; set; } + + /// + /// A helper that sets and stops immediately after + /// indicates the process has started. + /// + public bool StopBufferingAfterStarted { get; set; } +} diff --git a/src/Proc/ObservableProcess.cs b/src/Proc/ObservableProcess.cs index c8efa90..bf6d0b8 100644 --- a/src/Proc/ObservableProcess.cs +++ b/src/Proc/ObservableProcess.cs @@ -110,7 +110,12 @@ public IDisposable Subscribe(IObserver observerLines, IObserver + { + var keepBuffering = StartArguments.KeepBufferingLines ?? KeepBufferingLines; + var keep = keepBuffering?.Invoke(l); + return keep.GetValueOrDefault(true); + }) .Where(l => l != null) .Where(observeLinesFilter) .Subscribe( diff --git a/src/Proc/ObservableProcessBase.cs b/src/Proc/ObservableProcessBase.cs index 1fea092..9db4ba3 100644 --- a/src/Proc/ObservableProcessBase.cs +++ b/src/Proc/ObservableProcessBase.cs @@ -12,7 +12,7 @@ namespace ProcNet { - public delegate void StartedHandler(StreamWriter standardInput); + public delegate void StandardInputHandler(StreamWriter standardInput); public abstract class ObservableProcessBase : IObservableProcess where TConsoleOut : ConsoleOut @@ -24,6 +24,8 @@ protected ObservableProcessBase(StartArguments startArguments) { StartArguments = startArguments ?? throw new ArgumentNullException(nameof(startArguments)); Process = CreateProcess(); + if (startArguments.StandardInputHandler != null) + StandardInputReady += startArguments.StandardInputHandler; CreateObservable(); } @@ -31,7 +33,7 @@ protected ObservableProcessBase(StartArguments startArguments) public IDisposable Subscribe(IConsoleOutWriter writer) => OutStream.Subscribe(writer.Write, writer.Write, delegate { }); - private readonly ManualResetEvent _completedHandle = new ManualResetEvent(false); + private readonly ManualResetEvent _completedHandle = new(false); public StreamWriter StandardInput => Process.StandardInput; public string Binary => StartArguments.Binary; @@ -57,7 +59,7 @@ private void CreateObservable() protected abstract IObservable CreateConsoleOutObservable(); - public event StartedHandler ProcessStarted = (s) => { }; + public event StandardInputHandler StandardInputReady = (s) => { }; protected bool StartProcess(IObserver observer) { @@ -77,7 +79,7 @@ protected bool StartProcess(IObserver observer) // best effort, Process could have finished before even attempting to read .Id and .ProcessName // which can throw if the process exits in between } - ProcessStarted(Process.StandardInput); + StandardInputReady(Process.StandardInput); return true; } @@ -185,14 +187,13 @@ public bool WaitForCompletion(TimeSpan timeout) return false; } - private readonly object _unpackLock = new object(); - private readonly object _sendLock = new object(); - private bool _sentControlC = false; + private readonly object _unpackLock = new(); + private readonly object _sendLock = new(); + private bool _sentControlC; - public void SendControlC() + + public bool SendControlC(int processId) { - if (_sentControlC) return; - if (!ProcessId.HasValue) return; var platform = (int)Environment.OSVersion.Platform; var isWindows = platform != 4 && platform != 6 && platform != 128; if (isWindows) @@ -201,35 +202,37 @@ public void SendControlC() UnpackTempOutOfProcessSignalSender(path); lock (_sendLock) { - if (_sentControlC) return; - if (!ProcessId.HasValue) return; - var args = new StartArguments(path, ProcessId.Value.ToString(CultureInfo.InvariantCulture)) + var args = new StartArguments(path, processId.ToString(CultureInfo.InvariantCulture)) { WaitForExit = null, }; - var result = Proc.Start(args, TimeSpan.FromSeconds(2)); - _sentControlC = true; + var result = Proc.Start(args, TimeSpan.FromSeconds(5)); SendYesForBatPrompt(); + return result.ExitCode == 0; } } else { lock (_sendLock) { - if (_sentControlC) return; - if (!ProcessId.HasValue) return; // I wish .NET Core had signals baked in but looking at the corefx repos tickets this is not happening any time soon. - var args = new StartArguments("kill", "-SIGINT", ProcessId.Value.ToString(CultureInfo.InvariantCulture)) + var args = new StartArguments("kill", "-SIGINT", processId.ToString(CultureInfo.InvariantCulture)) { WaitForExit = null, }; - var result = Proc.Start(args, TimeSpan.FromSeconds(2)); - _sentControlC = true; + var result = Proc.Start(args, TimeSpan.FromSeconds(5)); + return result.ExitCode == 0; } - } + } + public void SendControlC() + { + if (_sentControlC) return; + if (!ProcessId.HasValue) return; + var success = SendControlC(ProcessId.Value); + _sentControlC = true; } protected void SendYesForBatPrompt() diff --git a/src/Proc/Proc.Start.cs b/src/Proc/Proc.Start.cs index 17c5310..affd7b2 100644 --- a/src/Proc/Proc.Start.cs +++ b/src/Proc/Proc.Start.cs @@ -10,29 +10,28 @@ public static partial class Proc { /// /// Default timeout for all the process started through Proc.Start() or Proc.Exec(). - /// Defaults to 10 minutes. + /// Defaults to infinity. /// - public static TimeSpan DefaultTimeout { get; } = TimeSpan.FromMinutes(10); + public static TimeSpan DefaultTimeout { get; } = new(0, 0, 0, 0, -1); /// Starts a program and captures the output while writing to the console in realtime during execution + /// The binary to execute + /// the commandline arguments to add to the invocation of /// An object holding a list of console out lines, the exit code and whether the process completed public static ProcessCaptureResult Start(string bin, params string[] arguments) => Start(bin, DefaultTimeout, arguments); /// Starts a program and captures the output while writing to the console in realtime during execution + /// The binary to execute /// The maximum runtime of the started program + /// the commandline arguments to add to the invocation of /// An object holding a list of console out lines, the exit code and whether the process completed public static ProcessCaptureResult Start(string bin, TimeSpan timeout, params string[] arguments) => - Start(bin, timeout, started: null, arguments: arguments); - - /// Starts a program and captures the output while writing to the console in realtime during execution - /// The maximum runtime of the started program - /// A callback when the process is ready to receive standard in writes - /// An object holding a list of console out lines, the exit code and whether the process completed - public static ProcessCaptureResult Start(string bin, TimeSpan timeout, StartedHandler started, params string[] arguments) => - Start(bin, timeout, null, started, arguments); + Start(bin, timeout, null, arguments); /// Starts a program and captures the output while writing to the console in realtime during execution + /// The binary to execute + /// the commandline arguments to add to the invocation of /// The maximum runtime of the started program /// /// An implementation of that takes care of writing to the console @@ -40,53 +39,33 @@ public static ProcessCaptureResult Start(string bin, TimeSpan timeout, StartedHa /// /// An object holding a list of console out lines, the exit code and whether the process completed public static ProcessCaptureResult Start(string bin, TimeSpan timeout, IConsoleOutWriter consoleOutWriter, params string[] arguments) => - Start(bin, timeout, consoleOutWriter, started: null, arguments: arguments); - - /// Starts a program and captures the output while writing to the console in realtime during execution - /// The maximum runtime of the started program - /// A callback when the process is ready to receive standard in writes - /// - /// An implementation of that takes care of writing to the console - /// defaults to which writes standard error messages in red - /// - /// An object holding a list of console out lines, the exit code and whether the process completed - public static ProcessCaptureResult Start(string bin, TimeSpan timeout, IConsoleOutWriter consoleOutWriter, StartedHandler started, params string[] arguments) => - Start(new StartArguments(bin, arguments), timeout, consoleOutWriter, started); + Start(new StartArguments(bin, arguments), timeout, consoleOutWriter); /// Starts a program and captures the output while writing to the console in realtime during execution + /// Encompasses all the options you can specify to start Proc processes /// An object holding a list of console out lines, the exit code and whether the process completed public static ProcessCaptureResult Start(StartArguments arguments) => Start(arguments, DefaultTimeout); /// Starts a program and captures the output while writing to the console in realtime during execution + /// Encompasses all the options you can specify to start Proc processes /// The maximum runtime of the started program /// An object holding a list of console out lines, the exit code and whether the process completed public static ProcessCaptureResult Start(StartArguments arguments, TimeSpan timeout) => Start(arguments, timeout, null); /// Starts a program and captures the output while writing to the console in realtime during execution + /// Encompasses all the options you can specify to start Proc processes /// The maximum runtime of the started program /// /// An implementation of that takes care of writing to the console /// defaults to which writes standard error messages in red /// /// An object holding a list of console out lines, the exit code and whether the process completed - public static ProcessCaptureResult Start(StartArguments arguments, TimeSpan timeout, IConsoleOutWriter consoleOutWriter) => - Start(arguments, timeout, consoleOutWriter, null); - - /// Starts a program and captures the output while writing to the console in realtime during execution - /// The maximum runtime of the started program - /// A callback when the process is ready to receive standard in writes - /// - /// An implementation of that takes care of writing to the console - /// defaults to which writes standard error messages in red - /// - /// An object holding a list of console out lines, the exit code and whether the process completed - public static ProcessCaptureResult Start(StartArguments arguments, TimeSpan timeout, IConsoleOutWriter consoleOutWriter, StartedHandler started) + public static ProcessCaptureResult Start(StartArguments arguments, TimeSpan timeout, IConsoleOutWriter consoleOutWriter) { using var composite = new CompositeDisposable(); var process = new ObservableProcess(arguments); - if (started != null) process.ProcessStarted += started; - consoleOutWriter = consoleOutWriter ?? new ConsoleOutColorWriter(); + consoleOutWriter ??= new ConsoleOutColorWriter(); Exception seenException = null; var consoleOut = new List(); @@ -97,8 +76,8 @@ public static ProcessCaptureResult Start(StartArguments arguments, TimeSpan time consoleOut.Add(l); }, e => seenException = e, - consoleOutWriter.Write, - consoleOutWriter.Write + l => consoleOutWriter.Write(l), + l => consoleOutWriter.Write(l) ) ); diff --git a/src/Proc/Proc.StartLongRunning.cs b/src/Proc/Proc.StartLongRunning.cs new file mode 100644 index 0000000..1fd53d6 --- /dev/null +++ b/src/Proc/Proc.StartLongRunning.cs @@ -0,0 +1,86 @@ +using System; +using System.Reactive.Disposables; +using System.Runtime.ExceptionServices; +using System.Threading; +using ProcNet.Extensions; +using ProcNet.Std; + +namespace ProcNet +{ + public static partial class Proc + { + + /// + /// Allows you to start long running processes and dispose them when needed. + /// It also optionally allows you to wait before returning the disposable + /// by inspecting the console out of the process to validate the 'true' starting confirmation of the process using + /// + /// + /// + /// A usecase for this could be starting a webserver or database and wait before it prints it startup confirmation + /// before returning. + /// + /// + /// Encompasses all the options you can specify to start Proc processes + /// Waits returning the before confirms the process started + /// + /// An implementation of that takes care of writing to the console + /// defaults to which writes standard error messages in red + /// + /// The exit code and whether the process completed + public static IDisposable StartLongRunning(LongRunningArguments arguments, TimeSpan waitForStartedConfirmation, IConsoleOutWriter consoleOutWriter = null) + { + var started = false; + var confirmWaitHandle = new ManualResetEvent(false); + var composite = new CompositeDisposable(); + var process = new ObservableProcess(arguments); + consoleOutWriter ??= new ConsoleOutColorWriter(); + + var startedConfirmation = arguments.StartedConfirmationHandler ?? (_ => true); + + if (arguments.StartedConfirmationHandler != null && arguments.StopBufferingAfterStarted) + arguments.KeepBufferingLines = _ => !started; + + Exception seenException = null; + composite.Add(process); + composite.Add(process.SubscribeLinesAndCharacters( + l => + { + if (startedConfirmation(l)) + { + confirmWaitHandle.Set(); + started = true; + } + }, + e => + { + seenException = e; + confirmWaitHandle.Set(); + }, + l => consoleOutWriter.Write(l), + l => consoleOutWriter.Write(l) + ) + ); + + if (seenException != null) ExceptionDispatchInfo.Capture(seenException).Throw(); + if (arguments.StartedConfirmationHandler == null) + { + confirmWaitHandle.Set(); + started = true; + } + else + { + var completed = confirmWaitHandle.WaitOne(waitForStartedConfirmation); + if (completed) return composite; + var pwd = arguments.WorkingDirectory; + var args = arguments.Args.NaivelyQuoteArguments(); + var printBinary = arguments.OnlyPrintBinaryInExceptionMessage + ? $"\"{arguments.Binary}\"" + : $"\"{arguments.Binary} {args}\"{(pwd == null ? string.Empty : $" pwd: {pwd}")}"; + throw new ProcExecException($"Could not yield started confirmation after {waitForStartedConfirmation} while running {printBinary}"); + } + + return composite; + } + } +} diff --git a/src/Proc/Proc.StartRedirected.cs b/src/Proc/Proc.StartRedirected.cs index 88017e9..2ed7e18 100644 --- a/src/Proc/Proc.StartRedirected.cs +++ b/src/Proc/Proc.StartRedirected.cs @@ -22,66 +22,71 @@ public static ProcessResult StartRedirected(IConsoleLineHandler lineHandler, str /// Start a program and get notified of lines in realtime through unlike /// This won't capture all lines on the returned object and won't default to writing to the Console. /// + /// Encompasses all the options you can specify to start Proc processes /// The maximum runtime of the started program /// /// An implementation of that receives every line as or the that occurs while running /// /// The exit code and whether the process completed public static ProcessResult StartRedirected(IConsoleLineHandler lineHandler, string bin, TimeSpan timeout, params string[] arguments) => - StartRedirected(lineHandler, bin, timeout, started: null, arguments: arguments); + StartRedirected(lineHandler, bin, timeout, standardInput: null, arguments: arguments); /// /// Start a program and get notified of lines in realtime through unlike /// This won't capture all lines on the returned object and won't default to writing to the Console. /// + /// Encompasses all the options you can specify to start Proc processes /// The maximum runtime of the started program - /// A callback when the process is ready to receive standard in writes + /// A callback when the process is ready to receive standard in writes /// /// An implementation of that receives every line as or the that occurs while running /// /// The exit code and whether the process completed - public static ProcessResult StartRedirected(IConsoleLineHandler lineHandler, string bin, TimeSpan timeout, StartedHandler started, params string[] arguments) => - StartRedirected(new StartArguments(bin, arguments), timeout, started, lineHandler); + public static ProcessResult StartRedirected(IConsoleLineHandler lineHandler, string bin, TimeSpan timeout, StandardInputHandler standardInput, params string[] arguments) => + StartRedirected(new StartArguments(bin, arguments), timeout, standardInput, lineHandler); /// /// Start a program and get notified of lines in realtime through unlike /// This won't capture all lines on the returned object and won't default to writing to the Console. /// + /// Encompasses all the options you can specify to start Proc processes /// /// An implementation of that receives every line as or the that occurs while running /// /// The exit code and whether the process completed public static ProcessResult StartRedirected(StartArguments arguments, IConsoleLineHandler lineHandler = null) => - StartRedirected(arguments, DefaultTimeout, started: null, lineHandler: lineHandler); + StartRedirected(arguments, DefaultTimeout, standardInput: null, lineHandler: lineHandler); /// /// Start a program and get notified of lines in realtime through unlike /// This won't capture all lines on the returned object and won't default to writing to the Console. /// + /// Encompasses all the options you can specify to start Proc processes /// The maximum runtime of the started program /// /// An implementation of that receives every line as or the that occurs while running /// /// The exit code and whether the process completed public static ProcessResult StartRedirected(StartArguments arguments, TimeSpan timeout, IConsoleLineHandler lineHandler = null) => - StartRedirected(arguments, timeout, started: null, lineHandler: lineHandler); + StartRedirected(arguments, timeout, standardInput: null, lineHandler: lineHandler); /// /// Start a program and get notified of lines in realtime through unlike /// This won't capture all lines on the returned object and won't default to writing to the Console. /// + /// Encompasses all the options you can specify to start Proc processes /// The maximum runtime of the started program - /// A callback when the process is ready to receive standard in writes + /// A callback when the process is ready to receive standard in writes /// /// An implementation of that receives every line as or the that occurs while running /// /// The exit code and whether the process completed - public static ProcessResult StartRedirected(StartArguments arguments, TimeSpan timeout, StartedHandler started, IConsoleLineHandler lineHandler = null) + public static ProcessResult StartRedirected(StartArguments arguments, TimeSpan timeout, StandardInputHandler standardInput, IConsoleLineHandler lineHandler = null) { using (var composite = new CompositeDisposable()) { var process = new ObservableProcess(arguments); - if (started != null) process.ProcessStarted += started; + if (standardInput != null) process.StandardInputReady += standardInput; Exception seenException = null; composite.Add(process); diff --git a/src/Proc/ProcessArgumentsBase.cs b/src/Proc/ProcessArgumentsBase.cs index fbe5abe..b20daac 100644 --- a/src/Proc/ProcessArgumentsBase.cs +++ b/src/Proc/ProcessArgumentsBase.cs @@ -21,5 +21,9 @@ public ProcessArgumentsBase(string binary, params string[] args) /// Set the current working directory public string WorkingDirectory { get; set; } + + /// Force arguments and the current working director NOT to be part of the exception message + public bool OnlyPrintBinaryInExceptionMessage { get; set; } + } -} \ No newline at end of file +} diff --git a/src/Proc/StartArguments.cs b/src/Proc/StartArguments.cs index ba2689a..1823102 100644 --- a/src/Proc/StartArguments.cs +++ b/src/Proc/StartArguments.cs @@ -22,6 +22,15 @@ public StartArguments(string binary, params string[] args) : base(binary, args) /// public bool NoWrapInThread { get; set; } + /// + /// return true to stop buffering lines. Allows callers to optionally short circuit reading + /// from stdout/stderr without closing the process. + /// This is great for long running processes to only buffer console output until + /// all information is parsed. + /// + /// True to end the buffering of char[] to lines of text + public Func KeepBufferingLines { get; set; } + /// Attempts to send control+c (SIGINT) to the process first public bool SendControlCFirst { get; set; } @@ -31,6 +40,11 @@ public StartArguments(string binary, params string[] args) : base(binary, args) /// public Func LineOutFilter { get; set; } + /// + /// Callback with a reference to standard in once the process has started and stdin can be written too + /// + public StandardInputHandler StandardInputHandler { get; set; } + private static readonly TimeSpan DefaultWaitForExit = TimeSpan.FromSeconds(10); /// diff --git a/tests/Proc.Tests.Binary/Program.cs b/tests/Proc.Tests.Binary/Program.cs index 25c2995..32273ba 100644 --- a/tests/Proc.Tests.Binary/Program.cs +++ b/tests/Proc.Tests.Binary/Program.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; @@ -7,7 +8,7 @@ namespace Proc.Tests.Binary { public static class Program { - public static int Main(string[] args) + public static async Task Main(string[] args) { if (args.Length == 0) { @@ -35,6 +36,8 @@ public static int Main(string[] args) if (testCase == nameof(ControlCNoWait).ToLowerInvariant()) return ControlCNoWait(); if (testCase == nameof(OverwriteLines).ToLowerInvariant()) return OverwriteLines(); if (testCase == nameof(InterMixedOutAndError).ToLowerInvariant()) return InterMixedOutAndError(); + if (testCase == nameof(LongRunning).ToLowerInvariant()) return await LongRunning(); + if (testCase == nameof(TrulyLongRunning).ToLowerInvariant()) return await TrulyLongRunning(); return 1; } @@ -146,6 +149,42 @@ private static int TrailingLines() return 60; } + private static async Task LongRunning() + { + for (var i = 0; i < 10; i++) + { + Console.WriteLine($"Starting up: {i}"); + await Task.Delay(20); + } + Console.WriteLine($"Started!"); + await Task.Delay(20); + for (var i = 0; i < 10; i++) + { + Console.WriteLine($"Data after startup: {i}"); + await Task.Delay(20); + } + + + return 0; + } + + private static async Task TrulyLongRunning() + { + for (var i = 0; i < 2; i++) + { + Console.WriteLine($"Starting up: {i}"); + await Task.Delay(TimeSpan.FromSeconds(1)); + } + Console.WriteLine($"Started!"); + await Task.Delay(TimeSpan.FromSeconds(3)); + for (var i = 0; i < 10; i++) + { + Console.WriteLine($"Data after startup: {i}"); + await Task.Delay(TimeSpan.FromSeconds(10)); + } + return 0; + } + private static int MoreText() { var output = @" diff --git a/tests/Proc.Tests/LongRunningTests.cs b/tests/Proc.Tests/LongRunningTests.cs new file mode 100644 index 0000000..bbe784d --- /dev/null +++ b/tests/Proc.Tests/LongRunningTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Diagnostics; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using ProcNet.Std; +using FluentAssertions; +using Xunit; + +namespace ProcNet.Tests +{ + public class LongRunningTests : TestsBase + { + [Fact] + public async Task LongRunningShouldSeeAllOutput() + { + var args = LongRunningTestCaseArguments("LongRunning"); + args.StartedConfirmationHandler = l => l.Line == "Started!"; + + var outputWriter = new LineOutputTestCases.TestConsoleOutWriter(); + using (var result = Proc.StartLongRunning(args, WaitTimeout, outputWriter)) + await Task.Delay(TimeSpan.FromSeconds(2)); + + var lines = outputWriter.Lines; + lines.Length.Should().BeGreaterThan(0); + lines.Should().Contain(s => s.StartsWith("Starting up:")); + lines.Should().Contain(s => s == "Started!"); + lines.Should().Contain(s => s.StartsWith("Data after startup:")); + } + + [Fact] + public async Task LongRunningShouldStopBufferingOutputWhenAsked() + { + var args = LongRunningTestCaseArguments("TrulyLongRunning"); + args.StartedConfirmationHandler = l => l.Line == "Started!"; + args.StopBufferingAfterStarted = true; + + var outputWriter = new LineOutputTestCases.TestConsoleOutWriter(); + var sw = Stopwatch.StartNew(); + + using (var result = Proc.StartLongRunning(args, WaitTimeout, outputWriter)) + { + sw.Elapsed.Should().BeGreaterThan(TimeSpan.FromSeconds(1)); + var lines = outputWriter.Lines; + lines.Length.Should().BeGreaterThan(0); + lines.Should().Contain(s => s.StartsWith("Starting up:")); + lines.Should().Contain(s => s == "Started!"); + lines.Should().NotContain(s => s.StartsWith("Data after startup:")); + await Task.Delay(TimeSpan.FromSeconds(2)); + lines.Should().NotContain(s => s.StartsWith("Data after startup:")); + } + // we dispose before the program's completion + sw.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(20)); + + } + + [Fact] + public async Task LongRunningWithoutConfirmationHandler() + { + var args = LongRunningTestCaseArguments("LongRunning"); + var outputWriter = new LineOutputTestCases.TestConsoleOutWriter(); + + using (var result = Proc.StartLongRunning(args, WaitTimeout, outputWriter)) + await Task.Delay(TimeSpan.FromSeconds(2)); + + var lines = outputWriter.Lines; + lines.Should().Contain(s => s.StartsWith("Starting up:")); + lines.Should().Contain(s => s == "Started!"); + lines.Should().Contain(s => s.StartsWith("Data after startup:")); + lines.Length.Should().BeGreaterThan(0); + } + } +} diff --git a/tests/Proc.Tests/ProcTestCases.cs b/tests/Proc.Tests/ProcTestCases.cs index 827f3f3..7ea7155 100644 --- a/tests/Proc.Tests/ProcTestCases.cs +++ b/tests/Proc.Tests/ProcTestCases.cs @@ -23,8 +23,9 @@ public class TestConsoleOutWriter : IConsoleOutWriter public void ReadKeyFirst() { var args = TestCaseArguments(nameof(ReadKeyFirst)); + args.StandardInputHandler = s => s.Write("y"); var writer = new TestConsoleOutWriter(); - var result = Proc.Start(args, WaitTimeout, writer, s => s.Write("y")); + var result = Proc.Start(args, WaitTimeout, writer); result.Completed.Should().BeTrue("completed"); result.ExitCode.Should().HaveValue(); result.ConsoleOut.Should().NotBeEmpty(); diff --git a/tests/Proc.Tests/ReadLineTestCases.cs b/tests/Proc.Tests/ReadLineTestCases.cs index b127a9c..de408e1 100644 --- a/tests/Proc.Tests/ReadLineTestCases.cs +++ b/tests/Proc.Tests/ReadLineTestCases.cs @@ -12,7 +12,7 @@ public void ReadKeyFirst() { var seen = new List(); var process = new ObservableProcess(TestCaseArguments(nameof(ReadKeyFirst))); - process.ProcessStarted += (standardInput) => + process.StandardInputReady += (standardInput) => { //this particular program does not output anything and expect user input immediatly //OnNext on the observable is only called on output so we need to write on the started event diff --git a/tests/Proc.Tests/TestsBase.cs b/tests/Proc.Tests/TestsBase.cs index bbc5a4d..9a6b167 100644 --- a/tests/Proc.Tests/TestsBase.cs +++ b/tests/Proc.Tests/TestsBase.cs @@ -26,7 +26,13 @@ private static string GetWorkingDir() } protected static StartArguments TestCaseArguments(string testcase) => - new StartArguments("dotnet", GetDll(), testcase) + new("dotnet", GetDll(), testcase) + { + WorkingDirectory = GetWorkingDir(), + }; + + protected static LongRunningArguments LongRunningTestCaseArguments(string testcase) => + new("dotnet", GetDll(), testcase) { WorkingDirectory = GetWorkingDir(), };