diff --git a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/TaskCompletionSourceWithCancellation.cs b/src/libraries/Common/src/System/Threading/Tasks/TaskCompletionSourceWithCancellation.cs similarity index 73% rename from src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/TaskCompletionSourceWithCancellation.cs rename to src/libraries/Common/src/System/Threading/Tasks/TaskCompletionSourceWithCancellation.cs index 4d2cba1a6750e8..a5d667e7180840 100644 --- a/src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/TaskCompletionSourceWithCancellation.cs +++ b/src/libraries/Common/src/System/Threading/Tasks/TaskCompletionSourceWithCancellation.cs @@ -2,11 +2,13 @@ // The .NET Foundation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using System.Threading; -using System.Threading.Tasks; - -namespace System.Net.Http +namespace System.Threading.Tasks { + /// + /// A that supports cancellation registration so that any + /// s contain the relevant , + /// while also avoiding unnecessary allocations for closure captures. + /// internal class TaskCompletionSourceWithCancellation : TaskCompletionSource { private CancellationToken _cancellationToken; diff --git a/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs b/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs index 6d411cac3506fe..67e2d5151ece5a 100644 --- a/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs +++ b/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs @@ -124,6 +124,7 @@ public void Refresh() { } public override string ToString() { throw null; } public void WaitForExit() { } public bool WaitForExit(int milliseconds) { throw null; } + public System.Threading.Tasks.Task WaitForExitAsync(System.Threading.CancellationToken cancellationToken = default) { throw null; } public bool WaitForInputIdle() { throw null; } public bool WaitForInputIdle(int milliseconds) { throw null; } } diff --git a/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj b/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj index 511c111c15bc07..cbbb5cb4d5085e 100644 --- a/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj +++ b/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj @@ -44,6 +44,9 @@ Common\Interop\Windows\Interop.Errors.cs + + Common\System\Threading\Tasks\TaskCompletionSourceWithCancellation.cs + diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.cs index e4de4f76f4f1d0..46c23d33bc8c8c 100644 --- a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.cs +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.cs @@ -10,6 +10,7 @@ using System.Runtime.Serialization; using System.Text; using System.Threading; +using System.Threading.Tasks; namespace System.Diagnostics { @@ -1340,6 +1341,107 @@ public bool WaitForExit(int milliseconds) return exited; } + /// + /// Instructs the Process component to wait for the associated process to exit, or + /// for the to be canceled. + /// + /// + /// Calling this method will set to . + /// + /// + /// A task that will complete when the process has exited, cancellation has been requested, + /// or an error occurs. + /// + public async Task WaitForExitAsync(CancellationToken cancellationToken = default) + { + // Because the process has already started by the time this method is called, + // we're in a race against the process to set up our exit handlers before the process + // exits. As a result, there are several different flows that must be handled: + // + // CASE 1: WE ENABLE EVENTS + // This is the "happy path". In this case we enable events. + // + // CASE 1.1: PROCESS EXITS OR IS CANCELED AFTER REGISTERING HANDLER + // This case continues the "happy path". The process exits or waiting is canceled after + // registering the handler and no special cases are needed. + // + // CASE 1.2: PROCESS EXITS BEFORE REGISTERING HANDLER + // It's possible that the process can exit after we enable events but before we reigster + // the handler. In that case we must check for exit after registering the handler. + // + // + // CASE 2: PROCESS EXITS BEFORE ENABLING EVENTS + // The process may exit before we attempt to enable events. In that case EnableRaisingEvents + // will throw an exception like this: + // System.InvalidOperationException : Cannot process request because the process (42) has exited. + // In this case we catch the InvalidOperationException. If the process has exited, our work + // is done and we return. If for any reason (now or in the future) enabling events fails + // and the process has not exited, bubble the exception up to the user. + // + // + // CASE 3: USER ALREADY ENABLED EVENTS + // In this case the user has already enabled raising events. Re-enabling events is a no-op + // as the value hasn't changed. However, no-op also means that if the process has already + // exited, EnableRaisingEvents won't throw an exception. + // + // CASE 3.1: PROCESS EXITS OR IS CANCELED AFTER REGISTERING HANDLER + // (See CASE 1.1) + // + // CASE 3.2: PROCESS EXITS BEFORE REGISTERING HANDLER + // (See CASE 1.2) + + if (!Associated) + { + throw new InvalidOperationException(SR.NoAssociatedProcess); + } + + if (!HasExited) + { + // Early out for cancellation before doing more expensive work + cancellationToken.ThrowIfCancellationRequested(); + } + + try + { + // CASE 1: We enable events + // CASE 2: Process exits before enabling events (and throws an exception) + // CASE 3: User already enabled events (no-op) + EnableRaisingEvents = true; + } + catch (InvalidOperationException) + { + // CASE 2: If the process has exited, our work is done, otherwise bubble the + // exception up to the user + if (HasExited) + { + return; + } + + throw; + } + + var tcs = new TaskCompletionSourceWithCancellation(); + + EventHandler handler = (s, e) => tcs.TrySetResult(null); + Exited += handler; + + try + { + if (HasExited) + { + // CASE 1.2 & CASE 3.2: Handle race where the process exits before registering the handler + return; + } + + // CASE 1.1 & CASE 3.1: Process exits or is canceled here + await tcs.WaitWithCancellationAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + Exited -= handler; + } + } + /// /// /// Instructs the component to start diff --git a/src/libraries/System.Diagnostics.Process/tests/ProcessTestBase.cs b/src/libraries/System.Diagnostics.Process/tests/ProcessTestBase.cs index 9b15ab99324e00..9933c3e91a3330 100644 --- a/src/libraries/System.Diagnostics.Process/tests/ProcessTestBase.cs +++ b/src/libraries/System.Diagnostics.Process/tests/ProcessTestBase.cs @@ -60,6 +60,18 @@ protected Process CreateProcess(Func method = null) return p; } + protected Process CreateProcess(Func> method) + { + Process p = null; + using (RemoteInvokeHandle handle = RemoteExecutor.Invoke(method, new RemoteInvokeOptions { Start = false })) + { + p = handle.Process; + handle.Process = null; + } + AddProcessForDispose(p); + return p; + } + protected Process CreateProcess(Func method, string arg, bool autoDispose = true) { Process p = null; diff --git a/src/libraries/System.Diagnostics.Process/tests/ProcessWaitingTests.cs b/src/libraries/System.Diagnostics.Process/tests/ProcessWaitingTests.cs index f57a5cee30ed99..f48abd474f5cfa 100644 --- a/src/libraries/System.Diagnostics.Process/tests/ProcessWaitingTests.cs +++ b/src/libraries/System.Diagnostics.Process/tests/ProcessWaitingTests.cs @@ -24,6 +24,24 @@ public void MultipleProcesses_StartAllKillAllWaitAll() foreach (Process p in processes) Assert.True(p.WaitForExit(WaitInMS)); } + [Fact] + public async Task MultipleProcesses_StartAllKillAllWaitAllAsync() + { + const int Iters = 10; + Process[] processes = Enumerable.Range(0, Iters).Select(_ => CreateProcessLong()).ToArray(); + + foreach (Process p in processes) p.Start(); + foreach (Process p in processes) p.Kill(); + foreach (Process p in processes) + { + using (var cts = new CancellationTokenSource(WaitInMS)) + { + await p.WaitForExitAsync(cts.Token); + Assert.True(p.HasExited); + } + } + } + [Fact] public void MultipleProcesses_SerialStartKillWait() { @@ -33,7 +51,24 @@ public void MultipleProcesses_SerialStartKillWait() Process p = CreateProcessLong(); p.Start(); p.Kill(); - p.WaitForExit(WaitInMS); + Assert.True(p.WaitForExit(WaitInMS)); + } + } + + [Fact] + public async Task MultipleProcesses_SerialStartKillWaitAsync() + { + const int Iters = 10; + for (int i = 0; i < Iters; i++) + { + Process p = CreateProcessLong(); + p.Start(); + p.Kill(); + using (var cts = new CancellationTokenSource(WaitInMS)) + { + await p.WaitForExitAsync(cts.Token); + Assert.True(p.HasExited); + } } } @@ -54,6 +89,28 @@ public void MultipleProcesses_ParallelStartKillWait() Task.WaitAll(Enumerable.Range(0, Tasks).Select(_ => Task.Run(work)).ToArray()); } + [Fact] + public async Task MultipleProcesses_ParallelStartKillWaitAsync() + { + const int Tasks = 4, ItersPerTask = 10; + Func work = async () => + { + for (int i = 0; i < ItersPerTask; i++) + { + Process p = CreateProcessLong(); + p.Start(); + p.Kill(); + using (var cts = new CancellationTokenSource(WaitInMS)) + { + await p.WaitForExitAsync(cts.Token); + Assert.True(p.HasExited); + } + } + }; + + await Task.WhenAll(Enumerable.Range(0, Tasks).Select(_ => Task.Run(work)).ToArray()); + } + [Theory] [InlineData(0)] // poll [InlineData(10)] // real timeout @@ -62,6 +119,21 @@ public void CurrentProcess_WaitNeverCompletes(int milliseconds) Assert.False(Process.GetCurrentProcess().WaitForExit(milliseconds)); } + [Theory] + [InlineData(0)] // poll + [InlineData(10)] // real timeout + public async Task CurrentProcess_WaitAsyncNeverCompletes(int milliseconds) + { + using (var cts = new CancellationTokenSource(milliseconds)) + { + CancellationToken token = cts.Token; + Process process = Process.GetCurrentProcess(); + OperationCanceledException ex = await Assert.ThrowsAnyAsync(() => process.WaitForExitAsync(token)); + Assert.Equal(token, ex.CancellationToken); + Assert.False(process.HasExited); + } + } + [Fact] public void SingleProcess_TryWaitMultipleTimesBeforeCompleting() { @@ -82,6 +154,60 @@ public void SingleProcess_TryWaitMultipleTimesBeforeCompleting() Assert.True(p.WaitForExit(0)); } + [Fact] + public async Task SingleProcess_TryWaitAsyncMultipleTimesBeforeCompleting() + { + Process p = CreateProcessLong(); + p.Start(); + + // Verify we can try to wait for the process to exit multiple times + + // First test with an already canceled token. Because the token is already canceled, + // WaitForExitAsync should complete synchronously + for (int i = 0; i < 2; i++) + { + var token = new CancellationToken(canceled: true); + Task t = p.WaitForExitAsync(token); + + Assert.Equal(TaskStatus.Canceled, t.Status); + + OperationCanceledException ex = await Assert.ThrowsAnyAsync(() => t); + Assert.Equal(token, ex.CancellationToken); + Assert.False(p.HasExited); + } + + // Next, test with a token that is canceled after the task is created to + // exercise event hookup and async cancellation + using (var cts = new CancellationTokenSource()) + { + CancellationToken token = cts.Token; + Task t = p.WaitForExitAsync(token); + cts.Cancel(); + + OperationCanceledException ex = await Assert.ThrowsAnyAsync(() => t); + Assert.Equal(token, ex.CancellationToken); + Assert.False(p.HasExited); + } + + // Then wait until it exits and concurrently kill it. + // There's a race condition here, in that we really want to test + // killing it while we're waiting, but we could end up killing it + // before hand, in which case we're simply not testing exactly + // what we wanted to test, but everything should still work. + _ = Task.Delay(10).ContinueWith(_ => p.Kill()); + + using (var cts = new CancellationTokenSource(WaitInMS)) + { + await p.WaitForExitAsync(cts.Token); + Assert.True(p.HasExited); + } + + // Waiting on an already exited process should complete synchronously + Assert.True(p.HasExited); + Task task = p.WaitForExitAsync(); + Assert.Equal(TaskStatus.RanToCompletion, task.Status); + } + [Theory] [InlineData(false)] [InlineData(true)] @@ -107,6 +233,38 @@ public async Task SingleProcess_WaitAfterExited(bool addHandlerBeforeStart) Assert.True(p.WaitForExit(0)); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SingleProcess_WaitAsyncAfterExited(bool addHandlerBeforeStart) + { + Process p = CreateProcessLong(); + p.EnableRaisingEvents = true; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (addHandlerBeforeStart) + { + p.Exited += delegate + { tcs.SetResult(true); }; + } + p.Start(); + if (!addHandlerBeforeStart) + { + p.Exited += delegate + { tcs.SetResult(true); }; + } + + p.Kill(); + Assert.True(await tcs.Task); + + var token = new CancellationToken(canceled: true); + await p.WaitForExitAsync(token); + Assert.True(p.HasExited); + + await p.WaitForExitAsync(); + Assert.True(p.HasExited); + } + [Theory] [InlineData(0)] [InlineData(1)] @@ -143,6 +301,40 @@ public void SingleProcess_CopiesShareExitInformation() } } + [Fact] + public async Task SingleProcess_CopiesShareExitAsyncInformation() + { + using Process p = CreateProcessLong(); + p.Start(); + + Process[] copies = Enumerable.Range(0, 3).Select(_ => Process.GetProcessById(p.Id)).ToArray(); + + using (var cts = new CancellationTokenSource(millisecondsDelay: 0)) + { + CancellationToken token = cts.Token; + OperationCanceledException ex = await Assert.ThrowsAnyAsync(() => p.WaitForExitAsync(token)); + Assert.Equal(token, ex.CancellationToken); + Assert.False(p.HasExited); + } + p.Kill(); + using (var cts = new CancellationTokenSource(WaitInMS)) + { + await p.WaitForExitAsync(cts.Token); + Assert.True(p.HasExited); + } + + using (var cts = new CancellationTokenSource(millisecondsDelay: 0)) + { + foreach (Process copy in copies) + { + // Since the process has already exited, waiting again does not throw (even if the token is canceled) because + // there's no work to do. + await copy.WaitForExitAsync(cts.Token); + Assert.True(copy.HasExited); + } + } + } + [Fact] public void WaitForPeerProcess() { @@ -169,11 +361,49 @@ public void WaitForPeerProcess() Assert.Equal(RemoteExecutor.SuccessExitCode, child2.ExitCode); } + [Fact] + public async Task WaitAsyncForPeerProcess() + { + using Process child1 = CreateProcessLong(); + child1.Start(); + + using Process child2 = CreateProcess(async peerId => + { + Process peer = Process.GetProcessById(int.Parse(peerId)); + Console.WriteLine("Signal"); + using (var cts = new CancellationTokenSource(WaitInMS)) + { + await peer.WaitForExitAsync(cts.Token); + Assert.True(peer.HasExited); + } + return RemoteExecutor.SuccessExitCode; + }, child1.Id.ToString()); + child2.StartInfo.RedirectStandardOutput = true; + child2.Start(); + char[] output = new char[6]; + child2.StandardOutput.Read(output, 0, output.Length); + Assert.Equal("Signal", new string(output)); // wait for the signal before killing the peer + + child1.Kill(); + using (var cts = new CancellationTokenSource(WaitInMS)) + { + await child1.WaitForExitAsync(cts.Token); + Assert.True(child1.HasExited); + } + using (var cts = new CancellationTokenSource(WaitInMS)) + { + await child2.WaitForExitAsync(cts.Token); + Assert.True(child2.HasExited); + } + + Assert.Equal(RemoteExecutor.SuccessExitCode, child2.ExitCode); + } + [Fact] public void WaitForSignal() { - const string expectedSignal = "Signal"; - const string successResponse = "Success"; + const string ExpectedSignal = "Signal"; + const string SuccessResponse = "Success"; const int timeout = 30 * 1000; // 30 seconds, to allow for very slow machines Process p = CreateProcessPortable(RemotelyInvokable.WriteLineReadLine); @@ -188,7 +418,7 @@ public void WaitForSignal() { linesReceived++; - if (e.Data == expectedSignal) + if (e.Data == ExpectedSignal) { mre.Set(); } @@ -207,13 +437,62 @@ public void WaitForSignal() using (StreamWriter writer = p.StandardInput) { - writer.WriteLine(successResponse); + writer.WriteLine(SuccessResponse); } Assert.True(p.WaitForExit(timeout), "Process has not exited"); Assert.Equal(RemotelyInvokable.SuccessExitCode, p.ExitCode); } + [Fact] + public async Task WaitAsyncForSignal() + { + const string expectedSignal = "Signal"; + const string successResponse = "Success"; + const int timeout = 5 * 1000; + + using Process p = CreateProcessPortable(RemotelyInvokable.WriteLineReadLine); + p.StartInfo.RedirectStandardInput = true; + p.StartInfo.RedirectStandardOutput = true; + using var mre = new ManualResetEventSlim(false); + + int linesReceived = 0; + p.OutputDataReceived += (s, e) => + { + if (e.Data != null) + { + linesReceived++; + + if (e.Data == expectedSignal) + { + mre.Set(); + } + } + }; + + p.Start(); + p.BeginOutputReadLine(); + + Assert.True(mre.Wait(timeout)); + Assert.Equal(1, linesReceived); + + // Wait a little bit to make sure process didn't exit on itself + Thread.Sleep(1); + Assert.False(p.HasExited, "Process has prematurely exited"); + + using (StreamWriter writer = p.StandardInput) + { + writer.WriteLine(successResponse); + } + + using (var cts = new CancellationTokenSource(timeout)) + { + await p.WaitForExitAsync(cts.Token); + Assert.True(p.HasExited, "Process has not exited"); + } + Assert.Equal(RemotelyInvokable.SuccessExitCode, p.ExitCode); + } + [Fact] public void WaitChain() { @@ -241,6 +520,52 @@ public void WaitChain() Assert.Equal(RemoteExecutor.SuccessExitCode, root.ExitCode); } + [Fact] + public async Task WaitAsyncChain() + { + Process root = CreateProcess(async () => + { + Process child1 = CreateProcess(async () => + { + Process child2 = CreateProcess(async () => + { + Process child3 = CreateProcess(() => RemoteExecutor.SuccessExitCode); + child3.Start(); + using (var cts = new CancellationTokenSource(WaitInMS)) + { + await child3.WaitForExitAsync(cts.Token); + Assert.True(child3.HasExited); + } + + return child3.ExitCode; + }); + child2.Start(); + using (var cts = new CancellationTokenSource(WaitInMS)) + { + await child2.WaitForExitAsync(cts.Token); + Assert.True(child2.HasExited); + } + + return child2.ExitCode; + }); + child1.Start(); + using (var cts = new CancellationTokenSource(WaitInMS)) + { + await child1.WaitForExitAsync(cts.Token); + Assert.True(child1.HasExited); + } + + return child1.ExitCode; + }); + root.Start(); + using (var cts = new CancellationTokenSource(WaitInMS)) + { + await root.WaitForExitAsync(cts.Token); + Assert.True(root.HasExited); + } + Assert.Equal(RemoteExecutor.SuccessExitCode, root.ExitCode); + } + [Fact] public void WaitForSelfTerminatingChild() { @@ -250,6 +575,33 @@ public void WaitForSelfTerminatingChild() Assert.NotEqual(RemoteExecutor.SuccessExitCode, child.ExitCode); } + [Fact] + public async Task WaitAsyncForSelfTerminatingChild() + { + Process child = CreateProcessPortable(RemotelyInvokable.SelfTerminate); + child.Start(); + using (var cts = new CancellationTokenSource(WaitInMS)) + { + await child.WaitForExitAsync(cts.Token); + Assert.True(child.HasExited); + } + Assert.NotEqual(RemoteExecutor.SuccessExitCode, child.ExitCode); + } + + [Fact] + public async Task WaitAsyncForProcess() + { + Process p = CreateSleepProcess(WaitInMS); + p.Start(); + + Task processTask = p.WaitForExitAsync(); + Task delayTask = Task.Delay(WaitInMS * 2); + + Task result = await Task.WhenAny(processTask, delayTask); + Assert.Equal(processTask, result); + Assert.True(p.HasExited); + } + [Fact] public void WaitForInputIdle_NotDirected_ThrowsInvalidOperationException() { @@ -263,5 +615,12 @@ public void WaitForExit_NotDirected_ThrowsInvalidOperationException() var process = new Process(); Assert.Throws(() => process.WaitForExit()); } + + [Fact] + public async Task WaitForExitAsync_NotDirected_ThrowsInvalidOperationException() + { + var process = new Process(); + await Assert.ThrowsAsync(() => process.WaitForExitAsync()); + } } } diff --git a/src/libraries/System.Net.Http/src/System.Net.Http.csproj b/src/libraries/System.Net.Http/src/System.Net.Http.csproj index 5fb677485c934b..e1a2f70f90c40d 100644 --- a/src/libraries/System.Net.Http/src/System.Net.Http.csproj +++ b/src/libraries/System.Net.Http/src/System.Net.Http.csproj @@ -159,7 +159,6 @@ - @@ -565,6 +564,9 @@ Common\System\Net\Mail\WhitespaceReader.cs + + Common\System\Threading\Tasks\TaskCompletionSourceWithCancellation.cs +