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 9116d196197614..0907c2dc7b69f1 100644 --- a/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs +++ b/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs @@ -176,6 +176,38 @@ protected void OnExited() { } public void Refresh() { } [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("ios")] [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("tvos")] + [System.Runtime.Versioning.SupportedOSPlatformAttribute("maccatalyst")] + public static System.Diagnostics.ProcessExitStatus Run(System.Diagnostics.ProcessStartInfo startInfo, System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("ios")] + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("tvos")] + [System.Runtime.Versioning.SupportedOSPlatformAttribute("maccatalyst")] + public static System.Diagnostics.ProcessExitStatus Run(string fileName, System.Collections.Generic.IList? arguments = null, System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("ios")] + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("tvos")] + [System.Runtime.Versioning.SupportedOSPlatformAttribute("maccatalyst")] + public static System.Diagnostics.ProcessTextOutput RunAndCaptureText(System.Diagnostics.ProcessStartInfo startInfo, System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("ios")] + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("tvos")] + [System.Runtime.Versioning.SupportedOSPlatformAttribute("maccatalyst")] + public static System.Diagnostics.ProcessTextOutput RunAndCaptureText(string fileName, System.Collections.Generic.IList? arguments = null, System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("ios")] + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("tvos")] + [System.Runtime.Versioning.SupportedOSPlatformAttribute("maccatalyst")] + public static System.Threading.Tasks.Task RunAndCaptureTextAsync(System.Diagnostics.ProcessStartInfo startInfo, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("ios")] + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("tvos")] + [System.Runtime.Versioning.SupportedOSPlatformAttribute("maccatalyst")] + public static System.Threading.Tasks.Task RunAndCaptureTextAsync(string fileName, System.Collections.Generic.IList? arguments = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("ios")] + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("tvos")] + [System.Runtime.Versioning.SupportedOSPlatformAttribute("maccatalyst")] + public static System.Threading.Tasks.Task RunAsync(System.Diagnostics.ProcessStartInfo startInfo, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("ios")] + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("tvos")] + [System.Runtime.Versioning.SupportedOSPlatformAttribute("maccatalyst")] + public static System.Threading.Tasks.Task RunAsync(string fileName, System.Collections.Generic.IList? arguments = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("ios")] + [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("tvos")] [System.Runtime.Versioning.SupportedOSPlatformAttribute("maccatalyst")] // this needs to come after the ios attribute due to limitations in the platform analyzer public bool Start() { throw null; } [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("ios")] @@ -320,6 +352,14 @@ public ProcessStartInfo(string fileName, System.Collections.Generic.IEnumerable< [System.Diagnostics.CodeAnalysis.AllowNullAttribute] public string WorkingDirectory { get { throw null; } set { } } } + public sealed partial class ProcessTextOutput + { + public ProcessTextOutput(System.Diagnostics.ProcessExitStatus exitStatus, string standardOutput, string standardError, int processId) { throw null; } + public System.Diagnostics.ProcessExitStatus ExitStatus { get { throw null; } } + public int ProcessId { get { throw null; } } + public string StandardError { get { throw null; } } + public string StandardOutput { get { throw null; } } + } [System.ComponentModel.DesignerAttribute("System.Diagnostics.Design.ProcessThreadDesigner, System.Design, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a")] public partial class ProcessThread : System.ComponentModel.Component { diff --git a/src/libraries/System.Diagnostics.Process/src/Resources/Strings.resx b/src/libraries/System.Diagnostics.Process/src/Resources/Strings.resx index da63478da67736..92a6dd8e10675e 100644 --- a/src/libraries/System.Diagnostics.Process/src/Resources/Strings.resx +++ b/src/libraries/System.Diagnostics.Process/src/Resources/Strings.resx @@ -363,8 +363,11 @@ InheritedHandles must not contain duplicates. - - UseShellExecute is not supported by StartAndForget. On Windows, shell execution may not create a new process, which would make it impossible to return a valid process ID. + + UseShellExecute is not supported by Process.{0}. On Windows, shell execution may not create a new process, which would make it impossible to return a valid process ID. + + + RedirectStandardOutput and RedirectStandardError must both be set to true when calling Process.{0} with a ProcessStartInfo. On Unix, it's impossible to obtain the exit status of a non-child process. 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 9e820236325805..bc1e3b89bb10de 100644 --- a/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj +++ b/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj @@ -24,6 +24,7 @@ + diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Scenarios.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Scenarios.cs index 9df4a1f51e50dc..e2a251d4373416 100644 --- a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Scenarios.cs +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Scenarios.cs @@ -3,6 +3,8 @@ using System.Collections.Generic; using System.Runtime.Versioning; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Win32.SafeHandles; namespace System.Diagnostics @@ -46,10 +48,7 @@ public static int StartAndForget(ProcessStartInfo startInfo) { ArgumentNullException.ThrowIfNull(startInfo); - if (startInfo.UseShellExecute) - { - throw new InvalidOperationException(SR.StartAndForget_UseShellExecuteNotSupported); - } + ThrowIfUseShellExecute(startInfo, nameof(StartAndForget)); using SafeProcessHandle processHandle = SafeProcessHandle.Start(startInfo, fallbackToNull: true); return processHandle.ProcessId; @@ -81,8 +80,258 @@ public static int StartAndForget(ProcessStartInfo startInfo) [UnsupportedOSPlatform("tvos")] [SupportedOSPlatform("maccatalyst")] public static int StartAndForget(string fileName, IList? arguments = null) + => StartAndForget(CreateStartInfo(fileName, arguments)); + + /// + /// Starts the process described by , waits for it to exit, and returns its exit status. + /// + /// The that contains the information used to start the process. + /// + /// The maximum amount of time to wait for the process to exit. + /// When , waits indefinitely. + /// If the process does not exit within the specified timeout, it is killed. + /// + /// The exit status of the process. + /// is . + /// + /// is set to . + /// + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("tvos")] + [SupportedOSPlatform("maccatalyst")] + public static ProcessExitStatus Run(ProcessStartInfo startInfo, TimeSpan? timeout = default) + { + ArgumentNullException.ThrowIfNull(startInfo); + + ThrowIfUseShellExecute(startInfo, nameof(Run)); + + using SafeProcessHandle processHandle = SafeProcessHandle.Start(startInfo); + + return timeout.HasValue + ? processHandle.WaitForExitOrKillOnTimeout(timeout.Value) + : processHandle.WaitForExit(); + } + + /// + /// Starts a process with the specified file name and optional arguments, waits for it to exit, and returns its exit status. + /// + /// The name of the application or document to start. + /// + /// The command-line arguments to pass to the process. Pass or an empty list + /// to start the process without additional arguments. + /// + /// + /// The maximum amount of time to wait for the process to exit. + /// When , waits indefinitely. + /// If the process does not exit within the specified timeout, it is killed. + /// + /// The exit status of the process. + /// is . + /// is empty. + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("tvos")] + [SupportedOSPlatform("maccatalyst")] + public static ProcessExitStatus Run(string fileName, IList? arguments = null, TimeSpan? timeout = default) + => Run(CreateStartInfo(fileName, arguments), timeout); + + /// + /// Asynchronously starts the process described by , waits for it to exit, and returns its exit status. + /// + /// The that contains the information used to start the process. + /// + /// A token to cancel the asynchronous operation. + /// If the token is canceled, the process is killed. + /// + /// A task that represents the asynchronous operation. The value of the task contains the exit status of the process. + /// is . + /// + /// is set to . + /// + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("tvos")] + [SupportedOSPlatform("maccatalyst")] + public static async Task RunAsync(ProcessStartInfo startInfo, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(startInfo); + + ThrowIfUseShellExecute(startInfo, nameof(RunAsync)); + + using SafeProcessHandle processHandle = SafeProcessHandle.Start(startInfo); + + return await processHandle.WaitForExitOrKillOnCancellationAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Asynchronously starts a process with the specified file name and optional arguments, waits for it to exit, and returns its exit status. + /// + /// The name of the application or document to start. + /// + /// The command-line arguments to pass to the process. Pass or an empty list + /// to start the process without additional arguments. + /// + /// + /// A token to cancel the asynchronous operation. + /// If the token is canceled, the process is killed. + /// + /// A task that represents the asynchronous operation. The value of the task contains the exit status of the process. + /// is . + /// is empty. + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("tvos")] + [SupportedOSPlatform("maccatalyst")] + public static Task RunAsync(string fileName, IList? arguments = null, CancellationToken cancellationToken = default) + => RunAsync(CreateStartInfo(fileName, arguments), cancellationToken); + + /// + /// Starts the process described by , captures its standard output and error, + /// waits for it to exit, and returns the captured text and exit status. + /// + /// The that contains the information used to start the process. + /// + /// The maximum amount of time to wait for the process to exit. + /// When , waits indefinitely. + /// If the process does not exit within the specified timeout, it is killed. + /// + /// The captured text output and exit status of the process. + /// is . + /// + /// is set to . + /// -or- + /// or is not set to . + /// + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("tvos")] + [SupportedOSPlatform("maccatalyst")] + public static ProcessTextOutput RunAndCaptureText(ProcessStartInfo startInfo, TimeSpan? timeout = default) + { + ArgumentNullException.ThrowIfNull(startInfo); + + ThrowIfUseShellExecute(startInfo, nameof(RunAndCaptureText)); + ThrowIfNotRedirected(startInfo, nameof(RunAndCaptureText)); + + long startTimestamp = Stopwatch.GetTimestamp(); + + using Process process = Start(startInfo)!; + + string standardOutput, standardError; + try + { + (standardOutput, standardError) = process.ReadAllText(timeout); + } + catch + { + try { process.Kill(); } catch { } + throw; + } + + ProcessExitStatus exitStatus; + if (timeout.HasValue) + { + TimeSpan elapsed = Stopwatch.GetElapsedTime(startTimestamp); + TimeSpan remaining = timeout.Value - elapsed; + remaining = remaining >= TimeSpan.Zero ? remaining : TimeSpan.Zero; + exitStatus = process.SafeHandle.WaitForExitOrKillOnTimeout(remaining); + } + else + { + exitStatus = process.SafeHandle.WaitForExit(); + } + + return new ProcessTextOutput(exitStatus, standardOutput, standardError, process.Id); + } + + /// + /// Starts a process with the specified file name and optional arguments, captures its standard output and error, + /// waits for it to exit, and returns the captured text and exit status. + /// + /// The name of the application or document to start. + /// + /// The command-line arguments to pass to the process. Pass or an empty list + /// to start the process without additional arguments. + /// + /// + /// The maximum amount of time to wait for the process to exit. + /// When , waits indefinitely. + /// If the process does not exit within the specified timeout, it is killed. + /// + /// The captured text output and exit status of the process. + /// is . + /// is empty. + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("tvos")] + [SupportedOSPlatform("maccatalyst")] + public static ProcessTextOutput RunAndCaptureText(string fileName, IList? arguments = null, TimeSpan? timeout = default) + => RunAndCaptureText(CreateStartInfoForCapture(fileName, arguments), timeout); + + /// + /// Asynchronously starts the process described by , captures its standard output and error, + /// waits for it to exit, and returns the captured text and exit status. + /// + /// The that contains the information used to start the process. + /// + /// A token to cancel the asynchronous operation. + /// If the token is canceled, the process is killed. + /// + /// A task that represents the asynchronous operation. The value of the task contains the captured text output and exit status of the process. + /// is . + /// + /// is set to . + /// -or- + /// or is not set to . + /// + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("tvos")] + [SupportedOSPlatform("maccatalyst")] + public static async Task RunAndCaptureTextAsync(ProcessStartInfo startInfo, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(fileName); + ArgumentNullException.ThrowIfNull(startInfo); + + ThrowIfUseShellExecute(startInfo, nameof(RunAndCaptureTextAsync)); + ThrowIfNotRedirected(startInfo, nameof(RunAndCaptureTextAsync)); + + using Process process = Start(startInfo)!; + + string standardOutput, standardError; + try + { + (standardOutput, standardError) = await process.ReadAllTextAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + try { process.Kill(); } catch { } + throw; + } + + ProcessExitStatus exitStatus = await process.SafeHandle.WaitForExitOrKillOnCancellationAsync(cancellationToken).ConfigureAwait(false); + + return new ProcessTextOutput(exitStatus, standardOutput, standardError, process.Id); + } + + /// + /// Asynchronously starts a process with the specified file name and optional arguments, captures its standard output and error, + /// waits for it to exit, and returns the captured text and exit status. + /// + /// The name of the application or document to start. + /// + /// The command-line arguments to pass to the process. Pass or an empty list + /// to start the process without additional arguments. + /// + /// + /// A token to cancel the asynchronous operation. + /// If the token is canceled, the process is killed. + /// + /// A task that represents the asynchronous operation. The value of the task contains the captured text output and exit status of the process. + /// is . + /// is empty. + [UnsupportedOSPlatform("ios")] + [UnsupportedOSPlatform("tvos")] + [SupportedOSPlatform("maccatalyst")] + public static Task RunAndCaptureTextAsync(string fileName, IList? arguments = null, CancellationToken cancellationToken = default) + => RunAndCaptureTextAsync(CreateStartInfoForCapture(fileName, arguments), cancellationToken); + + private static ProcessStartInfo CreateStartInfo(string fileName, IList? arguments) + { + ArgumentException.ThrowIfNullOrEmpty(fileName); ProcessStartInfo startInfo = new(fileName); if (arguments is not null) @@ -93,7 +342,32 @@ public static int StartAndForget(string fileName, IList? arguments = nul } } - return StartAndForget(startInfo); + return startInfo; + } + + private static ProcessStartInfo CreateStartInfoForCapture(string fileName, IList? arguments) + { + ProcessStartInfo startInfo = CreateStartInfo(fileName, arguments); + startInfo.RedirectStandardOutput = true; + startInfo.RedirectStandardError = true; + + return startInfo; + } + + private static void ThrowIfUseShellExecute(ProcessStartInfo startInfo, string methodName) + { + if (startInfo.UseShellExecute) + { + throw new InvalidOperationException(SR.Format(SR.UseShellExecuteNotSupportedForScenario, methodName)); + } + } + + private static void ThrowIfNotRedirected(ProcessStartInfo startInfo, string methodName) + { + if (!startInfo.RedirectStandardOutput || !startInfo.RedirectStandardError) + { + throw new InvalidOperationException(SR.Format(SR.RedirectStandardOutputAndErrorRequired, methodName)); + } } } } diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessTextOutput.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessTextOutput.cs new file mode 100644 index 00000000000000..d094cfb852c00b --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessTextOutput.cs @@ -0,0 +1,53 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace System.Diagnostics +{ + /// + /// Represents the captured text output and exit status of a completed process. + /// + public sealed class ProcessTextOutput + { + /// + /// Initializes a new instance of the class. + /// + /// The exit status of the process. + /// The captured standard output text. + /// The captured standard error text. + /// The process ID of the completed process. + /// + /// , , or is . + /// + public ProcessTextOutput(ProcessExitStatus exitStatus, string standardOutput, string standardError, int processId) + { + ArgumentNullException.ThrowIfNull(exitStatus); + ArgumentNullException.ThrowIfNull(standardOutput); + ArgumentNullException.ThrowIfNull(standardError); + + ExitStatus = exitStatus; + StandardOutput = standardOutput; + StandardError = standardError; + ProcessId = processId; + } + + /// + /// Gets the exit status of the process. + /// + public ProcessExitStatus ExitStatus { get; } + + /// + /// Gets the captured standard output text of the process. + /// + public string StandardOutput { get; } + + /// + /// Gets the captured standard error text of the process. + /// + public string StandardError { get; } + + /// + /// Gets the process ID of the completed process. + /// + public int ProcessId { get; } + } +} diff --git a/src/libraries/System.Diagnostics.Process/tests/Helpers.cs b/src/libraries/System.Diagnostics.Process/tests/Helpers.cs index cf5507a5ee26fc..5b3e0d2e0e0292 100644 --- a/src/libraries/System.Diagnostics.Process/tests/Helpers.cs +++ b/src/libraries/System.Diagnostics.Process/tests/Helpers.cs @@ -2,8 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Generic; using System.ComponentModel; using System.Security.Principal; +using System.Text; using System.Threading.Tasks; using Xunit.Sdk; @@ -11,6 +13,50 @@ namespace System.Diagnostics.Tests { internal static class Helpers { + // RemoteExecutor populates ProcessStartInfo.Arguments, but the fileName overloads + // take an argument list, so this helper maps the serialized argument string. + public static List? MapToArgumentList(ProcessStartInfo startInfo) + { + string arguments = startInfo.Arguments; + if (string.IsNullOrEmpty(arguments)) + { + return null; + } + + List list = new(); + StringBuilder builder = new(); + bool isQuoted = false; + + foreach (char c in arguments) + { + switch (c) + { + case '"' when !isQuoted: + isQuoted = true; + break; + case ' ' when !isQuoted: + case '"' when isQuoted: + if (builder.Length > 0) + { + list.Add(builder.ToString()); + builder.Clear(); + } + isQuoted = false; + break; + default: + builder.Append(c); + break; + } + } + + if (builder.Length > 0) + { + list.Add(builder.ToString()); + } + + return list; + } + public const int PassingTestTimeoutMilliseconds = 60_000; public static async Task RetryWithBackoff(Action action, int delayInMilliseconds = 10, int times = 10) diff --git a/src/libraries/System.Diagnostics.Process/tests/RunTests.cs b/src/libraries/System.Diagnostics.Process/tests/RunTests.cs new file mode 100644 index 00000000000000..3bb8366d0df9d8 --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/tests/RunTests.cs @@ -0,0 +1,360 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DotNet.RemoteExecutor; +using Xunit; + +namespace System.Diagnostics.Tests +{ + public class RunTests : ProcessTestBase + { + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(false)] + [InlineData(true)] + public async Task Run_ExitCodeIsReturned(bool useAsync) + { + using Process template = CreateProcess(static () => RemoteExecutor.SuccessExitCode); + + ProcessExitStatus exitStatus = useAsync + ? await Process.RunAsync(template.StartInfo) + : Process.Run(template.StartInfo); + + Assert.Equal(RemoteExecutor.SuccessExitCode, exitStatus.ExitCode); + Assert.False(exitStatus.Canceled); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(false)] + [InlineData(true)] + public async Task Run_WithFileName_ExitCodeIsReturned(bool useAsync) + { + using Process template = CreateProcess(static () => RemoteExecutor.SuccessExitCode); + List? arguments = Helpers.MapToArgumentList(template.StartInfo); + + ProcessExitStatus exitStatus = useAsync + ? await Process.RunAsync(template.StartInfo.FileName, arguments) + : Process.Run(template.StartInfo.FileName, arguments); + + Assert.Equal(RemoteExecutor.SuccessExitCode, exitStatus.ExitCode); + Assert.False(exitStatus.Canceled); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(false)] + [InlineData(true)] + public async Task Run_WithTimeout_ExitCodeIsReturned(bool useAsync) + { + using Process template = CreateProcess(static () => RemoteExecutor.SuccessExitCode); + + ProcessExitStatus exitStatus; + if (useAsync) + { + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + exitStatus = await Process.RunAsync(template.StartInfo, cts.Token); + } + else + { + exitStatus = Process.Run(template.StartInfo, TimeSpan.FromMinutes(1)); + } + + Assert.Equal(RemoteExecutor.SuccessExitCode, exitStatus.ExitCode); + Assert.False(exitStatus.Canceled); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(false)] + [InlineData(true)] + public async Task Run_WithTimeoutOrCancellation_KillsLongRunningProcess(bool useAsync) + { + using Process template = CreateSleepProcess((int)TimeSpan.FromHours(1).TotalMilliseconds); + + ProcessExitStatus exitStatus; + if (useAsync) + { + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + exitStatus = await Process.RunAsync(template.StartInfo, cts.Token); + } + else + { + exitStatus = Process.Run(template.StartInfo, TimeSpan.FromMilliseconds(100)); + } + + Assert.True(exitStatus.Canceled); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(false)] + [InlineData(true)] + public async Task RunAndCaptureText_CapturesOutput(bool useAsync) + { + using Process template = CreateProcess(static () => + { + Console.Write("hello"); + Console.Error.Write("world"); + return RemoteExecutor.SuccessExitCode; + }); + + template.StartInfo.RedirectStandardOutput = true; + template.StartInfo.RedirectStandardError = true; + + ProcessTextOutput result = useAsync + ? await Process.RunAndCaptureTextAsync(template.StartInfo) + : Process.RunAndCaptureText(template.StartInfo); + + Assert.Equal(RemoteExecutor.SuccessExitCode, result.ExitStatus.ExitCode); + Assert.False(result.ExitStatus.Canceled); + Assert.Equal("hello", result.StandardOutput); + Assert.Equal("world", result.StandardError); + Assert.True(result.ProcessId > 0); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(false)] + [InlineData(true)] + public async Task RunAndCaptureText_WithFileName_CapturesOutput(bool useAsync) + { + using Process template = CreateProcess(static () => + { + Console.Write("output"); + Console.Error.Write("error"); + return RemoteExecutor.SuccessExitCode; + }); + + List? arguments = Helpers.MapToArgumentList(template.StartInfo); + + ProcessTextOutput result = useAsync + ? await Process.RunAndCaptureTextAsync(template.StartInfo.FileName, arguments) + : Process.RunAndCaptureText(template.StartInfo.FileName, arguments); + + Assert.Equal(RemoteExecutor.SuccessExitCode, result.ExitStatus.ExitCode); + Assert.Equal("output", result.StandardOutput); + Assert.Equal("error", result.StandardError); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(false)] + [InlineData(true)] + public async Task RunAndCaptureText_EmptyOutput(bool useAsync) + { + using Process template = CreateProcess(static () => RemoteExecutor.SuccessExitCode); + + template.StartInfo.RedirectStandardOutput = true; + template.StartInfo.RedirectStandardError = true; + + ProcessTextOutput result = useAsync + ? await Process.RunAndCaptureTextAsync(template.StartInfo) + : Process.RunAndCaptureText(template.StartInfo); + + Assert.Equal(RemoteExecutor.SuccessExitCode, result.ExitStatus.ExitCode); + Assert.Equal(string.Empty, result.StandardOutput); + Assert.Equal(string.Empty, result.StandardError); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(false)] + [InlineData(true)] + public async Task RunAndCaptureText_WithTimeoutOrCancellation_CapturesOutput(bool useAsync) + { + using Process template = CreateProcess(static () => + { + Console.Write("captured"); + Console.Error.Write("errors"); + return RemoteExecutor.SuccessExitCode; + }); + + template.StartInfo.RedirectStandardOutput = true; + template.StartInfo.RedirectStandardError = true; + + ProcessTextOutput result; + if (useAsync) + { + using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + result = await Process.RunAndCaptureTextAsync(template.StartInfo, cts.Token); + } + else + { + result = Process.RunAndCaptureText(template.StartInfo, TimeSpan.FromMinutes(1)); + } + + Assert.Equal(RemoteExecutor.SuccessExitCode, result.ExitStatus.ExitCode); + Assert.False(result.ExitStatus.Canceled); + Assert.Equal("captured", result.StandardOutput); + Assert.Equal("errors", result.StandardError); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(false)] + [InlineData(true)] + public async Task RunAndCaptureText_WithTimeoutOrCancellation_KillsLongRunningProcess(bool useAsync) + { + using Process template = CreateSleepProcess((int)TimeSpan.FromHours(1).TotalMilliseconds); + + template.StartInfo.RedirectStandardOutput = true; + template.StartInfo.RedirectStandardError = true; + + if (useAsync) + { + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + await Assert.ThrowsAnyAsync(async () => + await Process.RunAndCaptureTextAsync(template.StartInfo, cts.Token)); + } + else + { + Assert.ThrowsAny(() => + Process.RunAndCaptureText(template.StartInfo, TimeSpan.FromMilliseconds(100))); + } + } + + [Theory] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(false, false, false)] + [InlineData(true, false, true)] + [InlineData(true, true, false)] + [InlineData(true, false, false)] + public async Task RunAndCaptureText_NotRedirected_ThrowsInvalidOperationException(bool useAsync, bool redirectOutput, bool redirectError) + { + ProcessStartInfo startInfo = new("someprocess") + { + RedirectStandardOutput = redirectOutput, + RedirectStandardError = redirectError + }; + + if (useAsync) + { + await Assert.ThrowsAsync(() => Process.RunAndCaptureTextAsync(startInfo)); + } + else + { + Assert.Throws(() => Process.RunAndCaptureText(startInfo)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Run_NullStartInfo_ThrowsArgumentNullException(bool useAsync) + { + if (useAsync) + { + await Assert.ThrowsAsync("startInfo", () => Process.RunAsync((ProcessStartInfo)null!)); + } + else + { + AssertExtensions.Throws("startInfo", () => Process.Run((ProcessStartInfo)null!)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Run_NullFileName_ThrowsArgumentNullException(bool useAsync) + { + if (useAsync) + { + await Assert.ThrowsAsync("fileName", () => Process.RunAsync((string)null!)); + } + else + { + AssertExtensions.Throws("fileName", () => Process.Run((string)null!)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Run_EmptyFileName_ThrowsArgumentException(bool useAsync) + { + if (useAsync) + { + await Assert.ThrowsAsync("fileName", () => Process.RunAsync(string.Empty)); + } + else + { + AssertExtensions.Throws("fileName", () => Process.Run(string.Empty)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunAndCaptureText_NullStartInfo_ThrowsArgumentNullException(bool useAsync) + { + if (useAsync) + { + await Assert.ThrowsAsync("startInfo", () => Process.RunAndCaptureTextAsync((ProcessStartInfo)null!)); + } + else + { + AssertExtensions.Throws("startInfo", () => Process.RunAndCaptureText((ProcessStartInfo)null!)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunAndCaptureText_NullFileName_ThrowsArgumentNullException(bool useAsync) + { + if (useAsync) + { + await Assert.ThrowsAsync("fileName", () => Process.RunAndCaptureTextAsync((string)null!)); + } + else + { + AssertExtensions.Throws("fileName", () => Process.RunAndCaptureText((string)null!)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunAndCaptureText_EmptyFileName_ThrowsArgumentException(bool useAsync) + { + if (useAsync) + { + await Assert.ThrowsAsync("fileName", () => Process.RunAndCaptureTextAsync(string.Empty)); + } + else + { + AssertExtensions.Throws("fileName", () => Process.RunAndCaptureText(string.Empty)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Run_UseShellExecute_ThrowsInvalidOperationException(bool useAsync) + { + ProcessStartInfo startInfo = new("someprocess") { UseShellExecute = true }; + + if (useAsync) + { + await Assert.ThrowsAsync(() => Process.RunAsync(startInfo)); + } + else + { + Assert.Throws(() => Process.Run(startInfo)); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RunAndCaptureText_UseShellExecute_ThrowsInvalidOperationException(bool useAsync) + { + ProcessStartInfo startInfo = new("someprocess") { UseShellExecute = true }; + + if (useAsync) + { + await Assert.ThrowsAsync(() => Process.RunAndCaptureTextAsync(startInfo)); + } + else + { + Assert.Throws(() => Process.RunAndCaptureText(startInfo)); + } + } + } +} diff --git a/src/libraries/System.Diagnostics.Process/tests/StartAndForget.cs b/src/libraries/System.Diagnostics.Process/tests/StartAndForget.cs index dc20e3fd9a8a45..ebdd85e9833275 100644 --- a/src/libraries/System.Diagnostics.Process/tests/StartAndForget.cs +++ b/src/libraries/System.Diagnostics.Process/tests/StartAndForget.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.IO; -using System.Text; using Microsoft.DotNet.RemoteExecutor; using Microsoft.Win32.SafeHandles; using Xunit; @@ -20,7 +19,7 @@ public void StartAndForget_StartsProcessAndReturnsValidPid(bool useProcessStartI using Process template = CreateSleepProcess((int)TimeSpan.FromHours(1).TotalMilliseconds); int pid = useProcessStartInfo ? Process.StartAndForget(template.StartInfo) - : Process.StartAndForget(template.StartInfo.FileName, MapToArgumentList(template.StartInfo)); + : Process.StartAndForget(template.StartInfo.FileName, Helpers.MapToArgumentList(template.StartInfo)); Assert.True(pid > 0); @@ -113,48 +112,6 @@ public void StartAndForget_WithUseShellExecute_ThrowsInvalidOperationException() Assert.Throws(() => Process.StartAndForget(startInfo)); } - // RemoteExecutor populates ProcessStartInfo.Arguments, but StartAndForget(fileName, arguments) - // takes an argument list, so this helper maps the serialized argument string for this test. - private static List? MapToArgumentList(ProcessStartInfo startInfo) - { - string arguments = startInfo.Arguments; - if (string.IsNullOrEmpty(arguments)) - { - return null; - } - List list = new(); - StringBuilder builder = new(); - bool isQuoted = false; - - foreach (char c in arguments) - { - switch (c) - { - case '"' when !isQuoted: - isQuoted = true; - break; - case ' ' when !isQuoted: - case '"' when isQuoted: - if (builder.Length > 0) - { - list.Add(builder.ToString()); - builder.Clear(); - } - isQuoted = false; - break; - default: - builder.Append(c); - break; - } - } - - if (builder.Length > 0) - { - list.Add(builder.ToString()); - } - - return list; - } } } diff --git a/src/libraries/System.Diagnostics.Process/tests/System.Diagnostics.Process.Tests.csproj b/src/libraries/System.Diagnostics.Process/tests/System.Diagnostics.Process.Tests.csproj index a9332ad98c999f..f1ed6055933db3 100644 --- a/src/libraries/System.Diagnostics.Process/tests/System.Diagnostics.Process.Tests.csproj +++ b/src/libraries/System.Diagnostics.Process/tests/System.Diagnostics.Process.Tests.csproj @@ -39,6 +39,7 @@ +