diff --git a/src/libraries/Common/src/Interop/Windows/Advapi32/Interop.ProcessOptions.cs b/src/libraries/Common/src/Interop/Windows/Advapi32/Interop.ProcessOptions.cs index 8ef5167d70edc6..893a0aaed9073c 100644 --- a/src/libraries/Common/src/Interop/Windows/Advapi32/Interop.ProcessOptions.cs +++ b/src/libraries/Common/src/Interop/Windows/Advapi32/Interop.ProcessOptions.cs @@ -43,6 +43,7 @@ internal static partial class StartupInfoOptions internal const int STARTF_USESTDHANDLES = 0x00000100; internal const int CREATE_UNICODE_ENVIRONMENT = 0x00000400; internal const int CREATE_NO_WINDOW = 0x08000000; + internal const int CREATE_NEW_PROCESS_GROUP = 0x00000200; } } } 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 d4cf2170b88b83..17366ae9811736 100644 --- a/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs +++ b/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs @@ -219,6 +219,8 @@ public ProcessStartInfo(string fileName, System.Collections.Generic.IEnumerable< public System.Collections.ObjectModel.Collection ArgumentList { get { throw null; } } [System.Diagnostics.CodeAnalysis.AllowNullAttribute] public string Arguments { get { throw null; } set { } } + [System.Runtime.Versioning.SupportedOSPlatformAttribute("windows")] + public bool CreateNewProcessGroup { get { throw null; } set { } } public bool CreateNoWindow { get { throw null; } set { } } [System.Runtime.Versioning.SupportedOSPlatformAttribute("windows")] [System.Diagnostics.CodeAnalysis.AllowNullAttribute] diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Windows.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Windows.cs index 06a2bd51d6d402..dc05e138a5aec6 100644 --- a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Windows.cs +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Windows.cs @@ -505,6 +505,7 @@ private unsafe bool StartWithCreateProcess(ProcessStartInfo startInfo) // set up the creation flags parameter int creationFlags = 0; if (startInfo.CreateNoWindow) creationFlags |= Interop.Advapi32.StartupInfoOptions.CREATE_NO_WINDOW; + if (startInfo.CreateNewProcessGroup) creationFlags |= Interop.Advapi32.StartupInfoOptions.CREATE_NEW_PROCESS_GROUP; // set up the environment block parameter string? environmentBlock = null; diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.Unix.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.Unix.cs index 9664815dcde794..196d57bf0b36b9 100644 --- a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.Unix.cs +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.Unix.cs @@ -52,5 +52,12 @@ public SecureString? Password get { throw new PlatformNotSupportedException(SR.Format(SR.ProcessStartSingleFeatureNotSupported, nameof(Password))); } set { throw new PlatformNotSupportedException(SR.Format(SR.ProcessStartSingleFeatureNotSupported, nameof(Password))); } } + + [SupportedOSPlatform("windows")] + public bool CreateNewProcessGroup + { + get { throw new PlatformNotSupportedException(SR.Format(SR.ProcessStartSingleFeatureNotSupported, nameof(CreateNewProcessGroup))); } + set { throw new PlatformNotSupportedException(SR.Format(SR.ProcessStartSingleFeatureNotSupported, nameof(CreateNewProcessGroup))); } + } } } diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.Windows.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.Windows.cs index 8869c1809341f3..6217aeb9c5a5b3 100644 --- a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.Windows.cs +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.Windows.cs @@ -46,5 +46,17 @@ public string Domain [CLSCompliant(false)] [SupportedOSPlatform("windows")] public SecureString? Password { get; set; } + + /// + /// Gets or sets a value indicating whether to start the process in a new process group. + /// + /// true if the process should be started in a new process group; otherwise, false. The default is false. + /// + /// When a process is created in a new process group, it becomes the root of a new process group. + /// An implicit call to SetConsoleCtrlHandler(NULL,TRUE) is made on behalf of the new process, this means that the new process has CTRL+C disabled. + /// This property is useful for preventing console control events sent to the child process from affecting the parent process. + /// + [SupportedOSPlatform("windows")] + public bool CreateNewProcessGroup { get; set; } } } diff --git a/src/libraries/System.Diagnostics.Process/tests/Interop.cs b/src/libraries/System.Diagnostics.Process/tests/Interop.cs index 969995e36d8a33..6bd2ddebbc8f6a 100644 --- a/src/libraries/System.Diagnostics.Process/tests/Interop.cs +++ b/src/libraries/System.Diagnostics.Process/tests/Interop.cs @@ -72,6 +72,11 @@ public struct SID_AND_ATTRIBUTES public int Attributes; } + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + public static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId); + [DllImport("kernel32.dll")] public static extern bool GetProcessWorkingSetSizeEx(SafeProcessHandle hProcess, out IntPtr lpMinimumWorkingSetSize, out IntPtr lpMaximumWorkingSetSize, out uint flags); diff --git a/src/libraries/System.Diagnostics.Process/tests/ProcessStartInfoTests.cs b/src/libraries/System.Diagnostics.Process/tests/ProcessStartInfoTests.cs index 728e52f520208f..2e896862d638b5 100644 --- a/src/libraries/System.Diagnostics.Process/tests/ProcessStartInfoTests.cs +++ b/src/libraries/System.Diagnostics.Process/tests/ProcessStartInfoTests.cs @@ -905,6 +905,29 @@ public void TestEnvironmentVariablesPropertyUnix() }); } + [Fact] + [PlatformSpecific(TestPlatforms.Windows)] + public void CreateNewProcessGroup_SetWindows_GetReturnsExpected() + { + ProcessStartInfo psi = new ProcessStartInfo(); + Assert.False(psi.CreateNewProcessGroup); + + psi.CreateNewProcessGroup = true; + Assert.True(psi.CreateNewProcessGroup); + + psi.CreateNewProcessGroup = false; + Assert.False(psi.CreateNewProcessGroup); + } + + [Fact] + [PlatformSpecific(TestPlatforms.AnyUnix)] + public void CreateNewProcessGroup_GetSetUnix_ThrowsPlatformNotSupportedException() + { + var info = new ProcessStartInfo(); + Assert.Throws(() => info.CreateNewProcessGroup); + Assert.Throws(() => info.CreateNewProcessGroup = true); + } + [Theory] [InlineData(null)] [InlineData("")] diff --git a/src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs b/src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs index 7f66696c6b2755..47c5ce05870287 100644 --- a/src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs +++ b/src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs @@ -1047,5 +1047,16 @@ private static string StartAndReadToEnd(string filename, string[] arguments) return process.StandardOutput.ReadToEnd(); } } + + private static void SendSignal(PosixSignal signal, int processId) + { + int result = kill(processId, Interop.Sys.GetPlatformSignalNumber(signal)); + if (result != 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), $"Failed to send signal {signal} to process {processId}"); + } + } + + private static unsafe void ReEnableCtrlCHandlerIfNeeded(PosixSignal signal) { } } } diff --git a/src/libraries/System.Diagnostics.Process/tests/ProcessTests.Windows.cs b/src/libraries/System.Diagnostics.Process/tests/ProcessTests.Windows.cs index 6fb91c0f4e113e..7850ca8afcbd7d 100644 --- a/src/libraries/System.Diagnostics.Process/tests/ProcessTests.Windows.cs +++ b/src/libraries/System.Diagnostics.Process/tests/ProcessTests.Windows.cs @@ -2,7 +2,11 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.ComponentModel; using System.IO; +using System.Runtime.InteropServices; +using Microsoft.DotNet.XUnitExtensions; +using Xunit; namespace System.Diagnostics.Tests { @@ -15,5 +19,41 @@ private string WriteScriptFile(string directory, string name, int returnValue) File.WriteAllText(filename, $"exit {returnValue}"); return filename; } + + private static void SendSignal(PosixSignal signal, int processId) + { + uint dwCtrlEvent = signal switch + { + PosixSignal.SIGINT => Interop.Kernel32.CTRL_C_EVENT, + PosixSignal.SIGQUIT => Interop.Kernel32.CTRL_BREAK_EVENT, + _ => throw new ArgumentOutOfRangeException(nameof(signal)) + }; + + if (!Interop.GenerateConsoleCtrlEvent(dwCtrlEvent, (uint)processId)) + { + int error = Marshal.GetLastWin32Error(); + if (error == Interop.Errors.ERROR_INVALID_FUNCTION && PlatformDetection.IsInContainer) + { + // Docker in CI runs without a console attached. + throw new SkipTestException($"GenerateConsoleCtrlEvent failed with ERROR_INVALID_FUNCTION. The process is not a console process or does not have a console."); + } + + throw new Win32Exception(error); + } + } + + // See https://learn.microsoft.com/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw#remarks: + // When a process is created with CREATE_NEW_PROCESS_GROUP specified, an implicit call to SetConsoleCtrlHandler(NULL,TRUE) + // is made on behalf of the new process; this means that the new process has CTRL+C disabled. + private static unsafe void ReEnableCtrlCHandlerIfNeeded(PosixSignal signal) + { + if (signal is PosixSignal.SIGINT) + { + if (!Interop.Kernel32.SetConsoleCtrlHandler(null, false)) + { + throw new Win32Exception(); + } + } + } } } diff --git a/src/libraries/System.Diagnostics.Process/tests/ProcessTests.cs b/src/libraries/System.Diagnostics.Process/tests/ProcessTests.cs index a3b2a7a97f0508..59f010f1941792 100644 --- a/src/libraries/System.Diagnostics.Process/tests/ProcessTests.cs +++ b/src/libraries/System.Diagnostics.Process/tests/ProcessTests.cs @@ -10,6 +10,7 @@ using System.Linq; using System.Net; using System.Reflection; +using System.Runtime.InteropServices; using System.Security; using System.Text; using System.Threading; @@ -80,6 +81,82 @@ private void AssertNonZeroAllZeroDarwin(long value) } } + public static IEnumerable SignalTestData() + { + if (OperatingSystem.IsWindows()) + { + // GenerateConsoleCtrlEvent only supports sending CTRL_C_EVENT and CTRL_BREAK_EVENT + yield return new object[] { PosixSignal.SIGINT }; + yield return new object[] { PosixSignal.SIGQUIT }; + } + else + { + foreach (PosixSignal signal in Enum.GetValues()) + { + yield return new object[] { signal }; + } + // Test a few raw signals. + yield return new object[] { (PosixSignal)3 }; // SIGQUIT + yield return new object[] { (PosixSignal)15 }; // SIGTERM + } + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [MemberData(nameof(SignalTestData))] + public void TestCreateNewProcessGroup_HandlerReceivesExpectedSignal(PosixSignal signal) + { + const string PosixSignalRegistrationCreatedMessage = "PosixSignalRegistration created..."; + + var remoteInvokeOptions = new RemoteInvokeOptions { CheckExitCode = false }; + remoteInvokeOptions.StartInfo.RedirectStandardOutput = true; + if (OperatingSystem.IsWindows()) + { + remoteInvokeOptions.StartInfo.CreateNewProcessGroup = true; + } + + using RemoteInvokeHandle remoteHandle = RemoteExecutor.Invoke( + (signalStr) => + { + PosixSignal expectedSignal = Enum.Parse(signalStr); + using ManualResetEvent receivedSignalEvent = new ManualResetEvent(false); + ReEnableCtrlCHandlerIfNeeded(expectedSignal); + + using PosixSignalRegistration p = PosixSignalRegistration.Create(expectedSignal, (ctx) => + { + Assert.Equal(expectedSignal, ctx.Signal); + receivedSignalEvent.Set(); + ctx.Cancel = true; + }); + + Console.WriteLine(PosixSignalRegistrationCreatedMessage); + + Assert.True(receivedSignalEvent.WaitOne(WaitInMS)); + + return 0; + }, + arg: $"{signal}", + remoteInvokeOptions); + + while (!remoteHandle.Process.StandardOutput.ReadLine().EndsWith(PosixSignalRegistrationCreatedMessage)) + { + Thread.Sleep(20); + } + + try + { + SendSignal(signal, remoteHandle.Process.Id); + + Assert.True(remoteHandle.Process.WaitForExit(WaitInMS)); + Assert.Equal(0, remoteHandle.Process.ExitCode); + } + finally + { + // If sending the signal fails, we want to kill the process ASAP + // to prevent RemoteExecutor's timeout from hiding it. + remoteHandle.Process.Kill(); + } + } + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] [InlineData(-2)] [InlineData((long)int.MaxValue + 1)] 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 a69d3eb2372059..718bddd017ea89 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 @@ -44,12 +44,18 @@ + + Link="Common\Interop\Windows\Interop.Libraries.cs" /> + Link="Common\Interop\Windows\Kernel32\Interop.LoadLibrary.cs" /> + + @@ -63,6 +69,10 @@ Link="Common\Interop\OSX\Interop.libproc.cs" /> + +