Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add WithExitCondition feature #242

Closed
wants to merge 7 commits into from

Conversation

dmilosz
Copy link

@dmilosz dmilosz commented Apr 20, 2024

Closes #241

Example usage:

var cmd = Cli.Wrap("powershell")
    .WithArguments("Start-Process -NoNewWindow -FilePath \"powershell\" -ArgumentList \"'sleep 1000'\"; exit 0")
    .WithWaitingForOutputProcessing(false)
    .WithStandardOutputPipe(PipeTarget.ToDelegate(line => Console.WriteLine($"STDOUT {line}")))
    .WithStandardErrorPipe(PipeTarget.ToDelegate(line => Console.WriteLine($"STDERR {line}")));


var result = await cmd.ExecuteAsync();

There is no breaking change. Current implementations will get true by default and will go with old, standard flow.

CliWrap/Command.cs Outdated Show resolved Hide resolved
@Tyrrrz Tyrrrz changed the title Added WaitForOutputProcessing feature Add WaitForOutputProcessing feature Apr 20, 2024
@Tyrrrz
Copy link
Owner

Tyrrrz commented Apr 20, 2024

I wonder if there's a way to implement this in another way -- by somehow instructing the child process not to share the stdout/err/in streams with its children. I wonder if it's possible? Is this issue specific to any OS?

@dmilosz
Copy link
Author

dmilosz commented Apr 21, 2024

I'm not aware of any method to tell grand children what streams to use. I think only the direct parent is capable of doing that.
The issue can also be reproduced with Ubuntu and bash, using nohup and background processing. Currently:

using CliWrap;

Console.WriteLine($"Program started at: {DateTime.Now.ToLongTimeString()}");

var cmd = Cli.Wrap("bash")
    .WithArguments(["-c", "nohup bash -c \"sleep 5; exit 0\" &"])
    .WithStandardOutputPipe(PipeTarget.ToDelegate(line => Console.WriteLine($"STDOUT {line}")))
    .WithStandardErrorPipe(PipeTarget.ToDelegate(line => Console.WriteLine($"STDERR {line}")));

var result = await cmd.ExecuteAsync();

Console.WriteLine($"Process started at: {result.StartTime.DateTime.ToLongTimeString()}");
Console.WriteLine($"Process finished with exit code: {result.ExitCode}");
Console.WriteLine($"Process finished at: {result.ExitTime.DateTime.ToLongTimeString()}");

Console.WriteLine($"Program finished at: {DateTime.Now.ToLongTimeString()}");

Output:

Program started at: 10:59:18
Process started at: 10:59:18
Process finished with exit code: 0
Process finished at: 10:59:18
Program finished at: 10:59:23

As you can see, the parent was finished almost instantly, but CliWrap waited for child process.

Now, after the change, using WithExitCondition(CommandExitCondition.ProcessExited):

using CliWrap;

Console.WriteLine($"Program started at: {DateTime.Now.ToLongTimeString()}");

var cmd = Cli.Wrap("bash")
    .WithArguments(["-c", "nohup bash -c \"sleep 5; exit 0\" &"])
    .WithExitCondition(CommandExitCondition.ProcessExited)
    .WithStandardOutputPipe(PipeTarget.ToDelegate(line => Console.WriteLine($"STDOUT {line}")))
    .WithStandardErrorPipe(PipeTarget.ToDelegate(line => Console.WriteLine($"STDERR {line}")));

var result = await cmd.ExecuteAsync();

Console.WriteLine($"Process started at: {result.StartTime.DateTime.ToLongTimeString()}");
Console.WriteLine($"Process finished with exit code: {result.ExitCode}");
Console.WriteLine($"Process finished at: {result.ExitTime.DateTime.ToLongTimeString()}");

Console.WriteLine($"Program finished at: {DateTime.Now.ToLongTimeString()}");

Output:

Program started at: 11:09:01
Process started at: 11:09:01
Process finished with exit code: 0
Process finished at: 11:09:01
Program finished at: 11:09:01

If you check processes after the program is finished, you can verify the bash child process is still running, after the program exits.

Copy link
Owner

@Tyrrrz Tyrrrz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please also add a test that fails with the current behavior, but works with the new option? You can probably use CliWrap.Tests.Dummy to spawn a child process.

CliWrap/CommandExitCondition.cs Outdated Show resolved Hide resolved
CliWrap/CommandExitCondition.cs Outdated Show resolved Hide resolved
CliWrap/CommandExitCondition.cs Outdated Show resolved Hide resolved
CliWrap/CommandExitCondition.cs Outdated Show resolved Hide resolved
CliWrap/CommandExitCondition.cs Outdated Show resolved Hide resolved
CliWrap/Command.cs Show resolved Hide resolved
@dmilosz
Copy link
Author

dmilosz commented Apr 22, 2024

Can you please also add a test that fails with the current behavior, but works with the new option? You can probably use CliWrap.Tests.Dummy to spawn a child process.

I've added 2 unit tests. They are not perfect, because the may fail unexpectedly when the machine is slow, since they depend on execution time. I don't have a better idea for a test which is 100% stable. However, child process is set to be running for 3 seconds so should be fine for most environments.

In the seconds test, if you remove .WithExitCondition(CommandExitCondition.ProcessExited); line, it will fail because child process will be definitely finished after the main process called by CLiWrap is done.

@dmilosz dmilosz changed the title Add WaitForOutputProcessing feature Add WithExitCondition feature Apr 22, 2024
CliWrap.Tests/ExitConditionSpecs.cs Outdated Show resolved Hide resolved
CliWrap.Tests/ExitConditionSpecs.cs Outdated Show resolved Hide resolved

// Act
var executionStart = DateTime.UtcNow;
var result = await cmd.ExecuteAsync();
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

executionStart/executionFinish is already provided by result

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, but finish time is a different one. CliWrap sets ExitTime when the main process ends. When there are child processes attached to output, ExecuteAsync() is blocked until all children are completed. In this test, we want to measure time when the ExecuteAsync() was blocked for, so that we can verify whether it was waiting for child or not.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exit time is set when the child process exits:

_nativeProcess.Exited += (_, _) =>
{
ExitTime = DateTimeOffset.Now;
_exitTcs.TrySetResult(null);
};

It shouldn't be affected by the exit condition you've added, since that only affects whether CliWrap waits for pipes to clear or not.

Because your var executionFinish = ... statement appears right await cmd.ExecuteAsync(), it's essentially the same (slightly later) timestamp as the one provided by ExecutionResult.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CliWrap's ExitTime is always the same no matter what ExitCondition is set. So this test would basically always pass.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused in that case. Wouldn't ExitCondition.ProcessExit make the command finish earlier, assuming it spawns a grandchild process?

Comment on lines +23 to +26
executionFinish
.Subtract(executionStart)
.Should()
.BeGreaterThanOrEqualTo(TimeSpan.FromSeconds(3));
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably a more reliable way to test this would be to read the stdout. The sleep command prints Done. after it finishes, so you can use that to detect whether the grandchild process has finished or not.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately it's not possible. The main process is finished almost instantly, and there is nothing we can do to catch the output after that. We can handle the output and wait for for child process to be finished, but then it breaks the whole point of doing this test, where we are interested in specific scenario when parent triggers child and then dies instantly.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant to test the current behavior (PipesClosed), wouldn't Done appear in the output since the command would only complete after the grandchild process exits?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only if we make main process to wait for the child. In the CliWrap.Tests.Dummy I implemented run process command that doesn't wait for the child, because this is the behavior that can't be handled currrently.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general it seems difficult to create a single test that works reliably on all environments including .NET framework and Linux. In Linux, we should somehow use nohup and background processing with bash to reproduce the issue, because the dummy app can't reproduce it.
On .NET Framework those tests also fail, but I don't know why yet.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought the issue is that, regardless of whether the child process waits for the grandchild or not, because the output streams are inherited and CliWrap waits for them to close, the command effectively finishes when all grandchildren exit.

@dmilosz
Copy link
Author

dmilosz commented Apr 29, 2024

I've implemented better solution in #243

@dmilosz dmilosz closed this Apr 29, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
2 participants