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

Process.WaitForExit: don't wait for standard streams when process was killed. #48101

Closed
wants to merge 2 commits into from

Conversation

tmds
Copy link
Member

@tmds tmds commented Feb 10, 2021

This is a common pattern:

process.Kill();
process.WaitForExit();

When the process has redirected standard output or standard error
WaitForExit will block until all descendants of the child process (that
inherited these streams) have also terminated.

This change will make WaitForExit no longer wait for these descendants
when the process was killed first.

Additionally, when the user has cancelled the reading by calling
CancelOutputRead and CancelErrorRead, WaitForExit will no longer
wait for the streams either.

@danmoseley @stephentoub @adamsitnik @eiriktsarpalis ptal

… killed.

This is a common pattern:

process.Kill();
process.WaitForExit();

When the process has redirected standard output or standard error
WaitForExit will block until all descendants of the child process (that
inherited these streams) have also terminated.

This change will make WaitForExit no longer wait for these descendants
when the process was killed first.

Additionally, when the user has cancelled the reading by calling
CancelOutputRead and CancelErrorRead, WaitForExit will no longer
wait for the streams either.
@ghost
Copy link

ghost commented Feb 10, 2021

Tagging subscribers to this area:
See info in area-owners.md if you want to be subscribed.

Issue Details

This is a common pattern:

process.Kill();
process.WaitForExit();

When the process has redirected standard output or standard error
WaitForExit will block until all descendants of the child process (that
inherited these streams) have also terminated.

This change will make WaitForExit no longer wait for these descendants
when the process was killed first.

Additionally, when the user has cancelled the reading by calling
CancelOutputRead and CancelErrorRead, WaitForExit will no longer
wait for the streams either.

@danmoseley @stephentoub @adamsitnik @eiriktsarpalis ptal

Author: tmds
Assignees: -
Labels:

area-System.Diagnostics.Process

Milestone: -

@stephentoub
Copy link
Member

stephentoub commented Feb 10, 2021

What's the reason for the proposed change?

@tmds
Copy link
Member Author

tmds commented Feb 10, 2021

What's the reason for the proposed change?

To better match with expectation that WaitForExit will return timely after a successful Kill.

The current behavior can introduce unexpected long blocking.

Users that are aware call WaitForExit with a timeout. If they pick the timeout that is 'too small', it may return before the process terminates and cause some unexpected behavior that occurs very infrequently and is hard to trace back.

@tmds
Copy link
Member Author

tmds commented Feb 10, 2021

The tests aren't passing on Windows.

Assert.Equal() Failure\r\n ↓ (pos 0)\r\nExpected: Sleep child started\r\nActual: The system cannot find the file specified···\r\n ↑ (pos 0)

I think it doesn't find "sleep".

@danmoseley
Copy link
Member

Is the current behavior the same on both Windows and Unix? What is the .NET Framework behavior? If we are matching that, it seems this change could be significantly breaking.

@tmds
Copy link
Member Author

tmds commented Feb 10, 2021

Behavior is the same on Windows and Unix.

The change is meant to be non-breaking:
The WaitUntilEOF in WaitForExit is meant as a way to ensure we've consumed all stdout/stderr from the app. We're using the Kill call from the user as an indication he's no longer interested.
For backwards-compatibility, we're faking a final null DataReceived event.

if (Interlocked.CompareExchange(ref _operationState, OperationStateReading, state) != state)
{
return;
}
Copy link
Member Author

Choose a reason for hiding this comment

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

Why does BeginReadLine ever makes calls to FlushMessageQueue?
I think that is ReadBufferAsync's responsibility?

Copy link
Member

Choose a reason for hiding this comment

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

Good question. I looked at the file history but it has only been modified twice: When it was introduced to .NET Core, and then when it was refactored to fix some async cancellation issues.

Maybe it's an optimization because in most cases, EOF is reached quite quickly? What do you think, @jozkee @adamsitnik ?

Copy link
Member

@carlossanlop carlossanlop left a comment

Choose a reason for hiding this comment

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

Thanks for your PR, @tmds. I left some feedback for you to consider.

@jozkee @adamsitnik let's talk about this PR in our next triage meeting to discuss any potential unintended breaking changes, and if we are ok to take them.

if (Interlocked.CompareExchange(ref _operationState, OperationStateReading, state) != state)
{
return;
}
Copy link
Member

Choose a reason for hiding this comment

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

Good question. I looked at the file history but it has only been modified twice: When it was introduced to .NET Core, and then when it was refactored to fix some async cancellation issues.

Maybe it's an optimization because in most cases, EOF is reached quite quickly? What do you think, @jozkee @adamsitnik ?

};
process.BeginOutputReadLine();
process.BeginErrorReadLine();
string childOutput = await childOutputTcs.Task.TimeoutAfter(30_000);
Copy link
Member

Choose a reason for hiding this comment

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

@tmds the TimeoutAfter extension method has been removed in this PR.

You will have to convert it to a WaitAsync extension method invocation instead.


Task asyncWaitTask = useAsyncAPI ? process.WaitForExitAsync() :
Task.Run(() => process.WaitForExit());
await asyncWaitTask.TimeoutAfter(30_000);
Copy link
Member

Choose a reason for hiding this comment

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

The sleep child is meant to quit after 10 minutes. Is there a point in waiting 30 seconds? Seems like a lot.

@carlossanlop carlossanlop added this to the Future milestone Mar 22, 2021
@jeffhandley
Copy link
Member

@carlossanlop, @jozkee, @adamsitnik -- please take a look at this in your next triage meeting to decide if we should take the behavioral change.

@jeffhandley
Copy link
Member

@adamsitnik, @carlossanlop, @jozkee -- merge conflicts have emerged on this, but we should decide if we want to take the behavioral change before we both resolving them.

@jeffhandley jeffhandley assigned adamsitnik and unassigned tmds Jul 3, 2021
@terrajobst terrajobst added the community-contribution Indicates that the PR has been added by a community member label Jul 19, 2021
@jeffhandley
Copy link
Member

@adamsitnik This PR is assigned to you for follow-up/decision before the RC1 snap.

@stephentoub
Copy link
Member

@jeffhandley, @adamsitnik, what is the plan here? Last comment is from July talking about doing something with this prior to the RC1 snap, which has already happened.

Copy link
Member

@adamsitnik adamsitnik left a comment

Choose a reason for hiding this comment

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

Hello @tmds

First of all, please accept my apologies for a very big delay.

To be honest with you, I am always hesistant to review PRs that try to change something non-trivial without disucssing it first.
If we discuss things first, and agree that given scenario needs to be fixed, you won't waste your time by working on a fix. As a maintainer I am going to have some context (the review will be easier) and I am also going to have the chance to see what other users think about it (the more upvotes, the higher priority on my TODO list).
If we don't and it turns out that current implementation works as expected, we are both not happy about the outcome: you have spent time on a change that won't get merged, while I have to reject a PR knowing that someone had good intentions and spent some time working on it.

In this particular PR we have two changes:

make WaitForExit no longer wait for these descendants
when the process was killed first

Currently, we wait for these descendants only when the user does not provide any timeout value, meaning that the user can wait up to infinity. I don't believe that this behavior should be changed. It's by design.

// If we have a hard timeout, we cannot wait for the streams
if (milliseconds == Timeout.Infinite)
{
_output?.EOF.GetAwaiter().GetResult();
_error?.EOF.GetAwaiter().GetResult();
}

if (exited && milliseconds == Timeout.Infinite) // if we have a hard timeout, we cannot wait for the streams
{
_output?.EOF.GetAwaiter().GetResult();
_error?.EOF.GetAwaiter().GetResult();
}

when the user has cancelled the reading by calling
CancelOutputRead and CancelErrorRead

I am suprised that Cancel methods don't actualy cancel anything. It seems that we set the flag to true:

internal void CancelOperation()
{
_cancelOperation = true;

But don't stop reading:

// Keep going until we're out of data to process.
while (true)

The actual async read operation cancellation is requested by Dispose method:

Would it not be simpler to just actually use the _cancelOperation flag in all while(true) loops and call _cts.Cancel(); from CancelOperation()?

But even with that, I am not sure if the stream used by async reader supports cancellation as on Windows we pass isAsync: false to the FileStream ctor:

_standardInput = new StreamWriter(new FileStream(parentInputPipeHandle!, FileAccess.Write, 4096, false), enc, 4096);

May I ask you to: create a new issue that describes the cancellation problem and send a new PR that addresses that? Once we have that PR, I am going to take a look at the Windows part and ensure that we are using a stream that supports cancellation. Once we get there, the issue can be closed (and the fix included in .NET 7).

@adamsitnik adamsitnik closed this Sep 9, 2021
@tmds
Copy link
Member Author

tmds commented Sep 9, 2021

Currently, we wait for these descendants only when the user does not provide any timeout value, meaning that the user can wait up to infinity. I don't believe that this behavior should be changed. It's by design.

By design means this is intentional.
If a user kills a process, why would he then want to wait up to infinity to read its output?

This is how I came to making this PR:

msbuild has some code like this:

process.Kill();
bool exited = process.WaitForExit(timeout);

The timeout is here to avoid the the infinite wait when a grand child still holds the terminal.

The msbuild repo also had a long standing open issue where things went wrong for an unclear reason that was hard to reproduce.

The root cause was a bug in the exited false path, which was very unlikely, but not impossible, to occur.

@ghost ghost locked as resolved and limited conversation to collaborators Oct 9, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-System.Diagnostics.Process community-contribution Indicates that the PR has been added by a community member
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants