diff --git a/CliWrap.Tests.Dummy/Commands/RunProcessCommand.cs b/CliWrap.Tests.Dummy/Commands/RunProcessCommand.cs new file mode 100644 index 00000000..b4d340f2 --- /dev/null +++ b/CliWrap.Tests.Dummy/Commands/RunProcessCommand.cs @@ -0,0 +1,40 @@ +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using CliFx; +using CliFx.Attributes; +using CliFx.Infrastructure; + +namespace CliWrap.Tests.Dummy.Commands; + +[Command("run process")] +public class RunProcessCommand : ICommand +{ + [CommandOption("path")] + public string FilePath { get; init; } = string.Empty; + + [CommandOption("arguments")] + public string Arguments { get; init; } = string.Empty; + + public ValueTask ExecuteAsync(IConsole console) + { + var startInfo = new ProcessStartInfo + { + FileName = FilePath, + Arguments = Arguments, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + var process = new Process(); + process.StartInfo = startInfo; + process.Start(); + + console.Output.WriteLine(process.Id); + + return default; + } +} diff --git a/CliWrap.Tests/ExitConditionSpecs.cs b/CliWrap.Tests/ExitConditionSpecs.cs new file mode 100644 index 00000000..1f75328f --- /dev/null +++ b/CliWrap.Tests/ExitConditionSpecs.cs @@ -0,0 +1,80 @@ +using System; +using System.Diagnostics; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using Xunit; + +namespace CliWrap.Tests; + +public class ExitConditionSpecs() +{ + [Fact(Timeout = 15000)] + public async Task I_can_execute_a_command_that_creates_child_process_reusing_standard_output_and_finish_after_child_process_exits() + { + // Arrange + var cmd = Cli.Wrap(Dummy.Program.FilePath) + .WithArguments( + [ + "run", + "process", + "--path", + Dummy.Program.FilePath, + "--arguments", + "sleep 00:00:03" + ] + ) + .WithStandardOutputPipe(PipeTarget.ToDelegate(_ => { })) + .WithStandardErrorPipe( + PipeTarget.ToDelegate(line => Console.WriteLine($"Error: {line}")) + ); + + // Act + var executionStart = DateTime.UtcNow; + var result = await cmd.ExecuteAsync(); + var executionFinish = DateTime.UtcNow; + + // Assert + executionFinish + .Subtract(executionStart) + .Should() + .BeGreaterThanOrEqualTo(TimeSpan.FromSeconds(3)); + } + + [Fact(Timeout = 15000)] + public async Task I_can_execute_a_command_that_creates_child_process_resuing_standard_output_and_finish_instantly_after_main_process_exits() + { + // Arrange + int childProcessId = -1; + var cmd = Cli.Wrap(Dummy.Program.FilePath) + .WithArguments( + [ + "run", + "process", + "--path", + Dummy.Program.FilePath, + "--arguments", + "sleep 00:00:03" + ] + ) + .WithStandardOutputPipe( + PipeTarget.ToDelegate(line => int.TryParse(line, out childProcessId)) + ) + .WithStandardErrorPipe( + PipeTarget.ToDelegate(line => Console.WriteLine($"Error: {line}")) + ) + .WithExitCondition(CommandExitCondition.ProcessExited); + + // Act + var executionStart = DateTime.UtcNow; + var result = await cmd.ExecuteAsync(); + var executionFinish = DateTime.UtcNow; + + var process = Process.GetProcessById(childProcessId); + + // Assert + executionFinish.Subtract(executionStart).Should().BeLessThan(TimeSpan.FromSeconds(3)); + + process.HasExited.Should().BeFalse(); + } +} diff --git a/CliWrap/Command.Execution.cs b/CliWrap/Command.Execution.cs index d5d74da5..62cc3f73 100644 --- a/CliWrap/Command.Execution.cs +++ b/CliWrap/Command.Execution.cs @@ -219,11 +219,17 @@ private async Task ExecuteAsync( .Register(process.Interrupt) .ToAsyncDisposable(); + // Create token to cancel piping when process is finished and we don't need to finish piping + using var outputProcessingCts = new CancellationTokenSource(); + using var pipeStdOutErrCts = CancellationTokenSource.CreateLinkedTokenSource( + forcefulCancellationToken, + outputProcessingCts.Token + ); // Start piping streams in the background var pipingTask = Task.WhenAll( PipeStandardInputAsync(process, stdInCts.Token), - PipeStandardOutputAsync(process, forcefulCancellationToken), - PipeStandardErrorAsync(process, forcefulCancellationToken) + PipeStandardOutputAsync(process, pipeStdOutErrCts.Token), + PipeStandardErrorAsync(process, pipeStdOutErrCts.Token) ); try @@ -239,8 +245,34 @@ private async Task ExecuteAsync( // If the pipe is still trying to transfer data, this will cause it to abort. await stdInCts.CancelAsync(); - // Wait until piping is done and propagate exceptions - await pipingTask.ConfigureAwait(false); + if (CommandExitCondition == CommandExitCondition.PipesClosed) + { + try + { + // Wait until piping is done and propagate exceptions + await pipingTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // throw original token if it was the source of cancel + forcefulCancellationToken.ThrowIfCancellationRequested(); + throw; + } + } + else if (CommandExitCondition == CommandExitCondition.ProcessExited) + { + try + { + // Cancel piping if we don't need to wait for it + await outputProcessingCts.CancelAsync(); + await pipingTask.ConfigureAwait(false); + } + catch (OperationCanceledException) { } + } + else + { + throw new NotImplementedException($"{CommandExitCondition} is not implemented."); + } } // Swallow exceptions caused by internal and user-provided cancellations, // because we have a separate mechanism for handling them below. diff --git a/CliWrap/Command.cs b/CliWrap/Command.cs index 27bed170..eca84f23 100644 --- a/CliWrap/Command.cs +++ b/CliWrap/Command.cs @@ -19,7 +19,8 @@ public partial class Command( CommandResultValidation validation, PipeSource standardInputPipe, PipeTarget standardOutputPipe, - PipeTarget standardErrorPipe + PipeTarget standardErrorPipe, + CommandExitCondition commandExitCondition ) : ICommandConfiguration { /// @@ -35,7 +36,8 @@ public Command(string targetFilePath) CommandResultValidation.ZeroExitCode, PipeSource.Null, PipeTarget.Null, - PipeTarget.Null + PipeTarget.Null, + CommandExitCondition.PipesClosed ) { } /// @@ -50,6 +52,9 @@ public Command(string targetFilePath) /// public Credentials Credentials { get; } = credentials; + /// + public CommandExitCondition CommandExitCondition { get; } = commandExitCondition; + /// public IReadOnlyDictionary EnvironmentVariables { get; } = environmentVariables; @@ -80,7 +85,8 @@ public Command WithTargetFile(string targetFilePath) => Validation, StandardInputPipe, StandardOutputPipe, - StandardErrorPipe + StandardErrorPipe, + CommandExitCondition ); /// @@ -101,7 +107,8 @@ public Command WithArguments(string arguments) => Validation, StandardInputPipe, StandardOutputPipe, - StandardErrorPipe + StandardErrorPipe, + CommandExitCondition ); /// @@ -147,7 +154,8 @@ public Command WithWorkingDirectory(string workingDirPath) => Validation, StandardInputPipe, StandardOutputPipe, - StandardErrorPipe + StandardErrorPipe, + CommandExitCondition ); /// @@ -164,7 +172,8 @@ public Command WithCredentials(Credentials credentials) => Validation, StandardInputPipe, StandardOutputPipe, - StandardErrorPipe + StandardErrorPipe, + CommandExitCondition ); /// @@ -196,7 +205,8 @@ public Command WithEnvironmentVariables( Validation, StandardInputPipe, StandardOutputPipe, - StandardErrorPipe + StandardErrorPipe, + CommandExitCondition ); /// @@ -226,7 +236,26 @@ public Command WithValidation(CommandResultValidation validation) => validation, StandardInputPipe, StandardOutputPipe, - StandardErrorPipe + StandardErrorPipe, + CommandExitCondition + ); + + /// + /// Creates a copy of this command, setting the exit condition to the specified value. + /// + [Pure] + public Command WithExitCondition(CommandExitCondition commandExitCondition) => + new( + TargetFilePath, + Arguments, + WorkingDirPath, + Credentials, + EnvironmentVariables, + Validation, + StandardInputPipe, + StandardOutputPipe, + StandardErrorPipe, + commandExitCondition ); /// @@ -243,7 +272,8 @@ public Command WithStandardInputPipe(PipeSource source) => Validation, source, StandardOutputPipe, - StandardErrorPipe + StandardErrorPipe, + CommandExitCondition ); /// @@ -260,7 +290,8 @@ public Command WithStandardOutputPipe(PipeTarget target) => Validation, StandardInputPipe, target, - StandardErrorPipe + StandardErrorPipe, + CommandExitCondition ); /// @@ -277,7 +308,8 @@ public Command WithStandardErrorPipe(PipeTarget target) => Validation, StandardInputPipe, StandardOutputPipe, - target + target, + CommandExitCondition ); /// diff --git a/CliWrap/CommandExitCondition.cs b/CliWrap/CommandExitCondition.cs new file mode 100644 index 00000000..b87deb20 --- /dev/null +++ b/CliWrap/CommandExitCondition.cs @@ -0,0 +1,18 @@ +namespace CliWrap; + +/// +/// Strategy used for identifying the end of command exectuion. +/// +public enum CommandExitCondition +{ + /// + /// Command execution is considered finished when the process exits and all standard input and output streams are closed. + /// + PipesClosed = 0, + + /// + /// Command execution is considered finished when the process exits, even if the process's standard input and output streams are still open, + /// for example after being inherited by a grandchild process. + /// + ProcessExited = 1 +} diff --git a/CliWrap/ICommandConfiguration.cs b/CliWrap/ICommandConfiguration.cs index 5a101d0b..63fd99f3 100644 --- a/CliWrap/ICommandConfiguration.cs +++ b/CliWrap/ICommandConfiguration.cs @@ -27,6 +27,11 @@ public interface ICommandConfiguration /// Credentials Credentials { get; } + /// + /// Strategy used for veryfing the end of command exectuion. + /// + public CommandExitCondition CommandExitCondition { get; } + /// /// Environment variables set for the underlying process. ///