Skip to content

Commit 07cf21a

Browse files
authored
Add CreateNewProcessGroup to ProcessStartInfo (#117105)
* Add CreateNewProcessGroup * Add RemoteExecutor.IsSupported condition * Use ManualResetEvent instead of local bool * Throw instead of asserting on Win pinvoke to get more info about the underlying error * MRE.WaitOne call should be asserted * Switch to memberdata to avoid pulluting the console log with skips * wrap SendSignal in try-finally and kill remote process to avoid hiding SendSignal exception. * Skip test on Docker * Guard SkipTestException with PlatformDetection.IsInContainer * Add documentation
1 parent 56ae94c commit 07cf21a

File tree

11 files changed

+191
-2
lines changed

11 files changed

+191
-2
lines changed

src/libraries/Common/src/Interop/Windows/Advapi32/Interop.ProcessOptions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ internal static partial class StartupInfoOptions
4343
internal const int STARTF_USESTDHANDLES = 0x00000100;
4444
internal const int CREATE_UNICODE_ENVIRONMENT = 0x00000400;
4545
internal const int CREATE_NO_WINDOW = 0x08000000;
46+
internal const int CREATE_NEW_PROCESS_GROUP = 0x00000200;
4647
}
4748
}
4849
}

src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ public ProcessStartInfo(string fileName, System.Collections.Generic.IEnumerable<
219219
public System.Collections.ObjectModel.Collection<string> ArgumentList { get { throw null; } }
220220
[System.Diagnostics.CodeAnalysis.AllowNullAttribute]
221221
public string Arguments { get { throw null; } set { } }
222+
[System.Runtime.Versioning.SupportedOSPlatformAttribute("windows")]
223+
public bool CreateNewProcessGroup { get { throw null; } set { } }
222224
public bool CreateNoWindow { get { throw null; } set { } }
223225
[System.Runtime.Versioning.SupportedOSPlatformAttribute("windows")]
224226
[System.Diagnostics.CodeAnalysis.AllowNullAttribute]

src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Windows.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,7 @@ private unsafe bool StartWithCreateProcess(ProcessStartInfo startInfo)
505505
// set up the creation flags parameter
506506
int creationFlags = 0;
507507
if (startInfo.CreateNoWindow) creationFlags |= Interop.Advapi32.StartupInfoOptions.CREATE_NO_WINDOW;
508+
if (startInfo.CreateNewProcessGroup) creationFlags |= Interop.Advapi32.StartupInfoOptions.CREATE_NEW_PROCESS_GROUP;
508509

509510
// set up the environment block parameter
510511
string? environmentBlock = null;

src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.Unix.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,12 @@ public SecureString? Password
5252
get { throw new PlatformNotSupportedException(SR.Format(SR.ProcessStartSingleFeatureNotSupported, nameof(Password))); }
5353
set { throw new PlatformNotSupportedException(SR.Format(SR.ProcessStartSingleFeatureNotSupported, nameof(Password))); }
5454
}
55+
56+
[SupportedOSPlatform("windows")]
57+
public bool CreateNewProcessGroup
58+
{
59+
get { throw new PlatformNotSupportedException(SR.Format(SR.ProcessStartSingleFeatureNotSupported, nameof(CreateNewProcessGroup))); }
60+
set { throw new PlatformNotSupportedException(SR.Format(SR.ProcessStartSingleFeatureNotSupported, nameof(CreateNewProcessGroup))); }
61+
}
5562
}
5663
}

src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.Windows.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,17 @@ public string Domain
4646
[CLSCompliant(false)]
4747
[SupportedOSPlatform("windows")]
4848
public SecureString? Password { get; set; }
49+
50+
/// <summary>
51+
/// Gets or sets a value indicating whether to start the process in a new process group.
52+
/// </summary>
53+
/// <value><c>true</c> if the process should be started in a new process group; otherwise, <c>false</c>. The default is <c>false</c>.</value>
54+
/// <remarks>
55+
/// <para>When a process is created in a new process group, it becomes the root of a new process group.</para>
56+
/// <para>An implicit call to <c>SetConsoleCtrlHandler(NULL,TRUE)</c> is made on behalf of the new process, this means that the new process has CTRL+C disabled.</para>
57+
/// <para>This property is useful for preventing console control events sent to the child process from affecting the parent process.</para>
58+
/// </remarks>
59+
[SupportedOSPlatform("windows")]
60+
public bool CreateNewProcessGroup { get; set; }
4961
}
5062
}

src/libraries/System.Diagnostics.Process/tests/Interop.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ public struct SID_AND_ATTRIBUTES
7272
public int Attributes;
7373
}
7474

75+
76+
[DllImport("kernel32.dll", SetLastError = true)]
77+
[return: MarshalAs(UnmanagedType.Bool)]
78+
public static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);
79+
7580
[DllImport("kernel32.dll")]
7681
public static extern bool GetProcessWorkingSetSizeEx(SafeProcessHandle hProcess, out IntPtr lpMinimumWorkingSetSize, out IntPtr lpMaximumWorkingSetSize, out uint flags);
7782

src/libraries/System.Diagnostics.Process/tests/ProcessStartInfoTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -905,6 +905,29 @@ public void TestEnvironmentVariablesPropertyUnix()
905905
});
906906
}
907907

908+
[Fact]
909+
[PlatformSpecific(TestPlatforms.Windows)]
910+
public void CreateNewProcessGroup_SetWindows_GetReturnsExpected()
911+
{
912+
ProcessStartInfo psi = new ProcessStartInfo();
913+
Assert.False(psi.CreateNewProcessGroup);
914+
915+
psi.CreateNewProcessGroup = true;
916+
Assert.True(psi.CreateNewProcessGroup);
917+
918+
psi.CreateNewProcessGroup = false;
919+
Assert.False(psi.CreateNewProcessGroup);
920+
}
921+
922+
[Fact]
923+
[PlatformSpecific(TestPlatforms.AnyUnix)]
924+
public void CreateNewProcessGroup_GetSetUnix_ThrowsPlatformNotSupportedException()
925+
{
926+
var info = new ProcessStartInfo();
927+
Assert.Throws<PlatformNotSupportedException>(() => info.CreateNewProcessGroup);
928+
Assert.Throws<PlatformNotSupportedException>(() => info.CreateNewProcessGroup = true);
929+
}
930+
908931
[Theory]
909932
[InlineData(null)]
910933
[InlineData("")]

src/libraries/System.Diagnostics.Process/tests/ProcessTests.Unix.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,5 +1047,16 @@ private static string StartAndReadToEnd(string filename, string[] arguments)
10471047
return process.StandardOutput.ReadToEnd();
10481048
}
10491049
}
1050+
1051+
private static void SendSignal(PosixSignal signal, int processId)
1052+
{
1053+
int result = kill(processId, Interop.Sys.GetPlatformSignalNumber(signal));
1054+
if (result != 0)
1055+
{
1056+
throw new Win32Exception(Marshal.GetLastWin32Error(), $"Failed to send signal {signal} to process {processId}");
1057+
}
1058+
}
1059+
1060+
private static unsafe void ReEnableCtrlCHandlerIfNeeded(PosixSignal signal) { }
10501061
}
10511062
}

src/libraries/System.Diagnostics.Process/tests/ProcessTests.Windows.cs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System;
5+
using System.ComponentModel;
56
using System.IO;
7+
using System.Runtime.InteropServices;
8+
using Microsoft.DotNet.XUnitExtensions;
9+
using Xunit;
610

711
namespace System.Diagnostics.Tests
812
{
@@ -15,5 +19,41 @@ private string WriteScriptFile(string directory, string name, int returnValue)
1519
File.WriteAllText(filename, $"exit {returnValue}");
1620
return filename;
1721
}
22+
23+
private static void SendSignal(PosixSignal signal, int processId)
24+
{
25+
uint dwCtrlEvent = signal switch
26+
{
27+
PosixSignal.SIGINT => Interop.Kernel32.CTRL_C_EVENT,
28+
PosixSignal.SIGQUIT => Interop.Kernel32.CTRL_BREAK_EVENT,
29+
_ => throw new ArgumentOutOfRangeException(nameof(signal))
30+
};
31+
32+
if (!Interop.GenerateConsoleCtrlEvent(dwCtrlEvent, (uint)processId))
33+
{
34+
int error = Marshal.GetLastWin32Error();
35+
if (error == Interop.Errors.ERROR_INVALID_FUNCTION && PlatformDetection.IsInContainer)
36+
{
37+
// Docker in CI runs without a console attached.
38+
throw new SkipTestException($"GenerateConsoleCtrlEvent failed with ERROR_INVALID_FUNCTION. The process is not a console process or does not have a console.");
39+
}
40+
41+
throw new Win32Exception(error);
42+
}
43+
}
44+
45+
// See https://learn.microsoft.com/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw#remarks:
46+
// When a process is created with CREATE_NEW_PROCESS_GROUP specified, an implicit call to SetConsoleCtrlHandler(NULL,TRUE)
47+
// is made on behalf of the new process; this means that the new process has CTRL+C disabled.
48+
private static unsafe void ReEnableCtrlCHandlerIfNeeded(PosixSignal signal)
49+
{
50+
if (signal is PosixSignal.SIGINT)
51+
{
52+
if (!Interop.Kernel32.SetConsoleCtrlHandler(null, false))
53+
{
54+
throw new Win32Exception();
55+
}
56+
}
57+
}
1858
}
1959
}

src/libraries/System.Diagnostics.Process/tests/ProcessTests.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
using System.Linq;
1111
using System.Net;
1212
using System.Reflection;
13+
using System.Runtime.InteropServices;
1314
using System.Security;
1415
using System.Text;
1516
using System.Threading;
@@ -80,6 +81,82 @@ private void AssertNonZeroAllZeroDarwin(long value)
8081
}
8182
}
8283

84+
public static IEnumerable<object[]> SignalTestData()
85+
{
86+
if (OperatingSystem.IsWindows())
87+
{
88+
// GenerateConsoleCtrlEvent only supports sending CTRL_C_EVENT and CTRL_BREAK_EVENT
89+
yield return new object[] { PosixSignal.SIGINT };
90+
yield return new object[] { PosixSignal.SIGQUIT };
91+
}
92+
else
93+
{
94+
foreach (PosixSignal signal in Enum.GetValues<PosixSignal>())
95+
{
96+
yield return new object[] { signal };
97+
}
98+
// Test a few raw signals.
99+
yield return new object[] { (PosixSignal)3 }; // SIGQUIT
100+
yield return new object[] { (PosixSignal)15 }; // SIGTERM
101+
}
102+
}
103+
104+
[ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
105+
[MemberData(nameof(SignalTestData))]
106+
public void TestCreateNewProcessGroup_HandlerReceivesExpectedSignal(PosixSignal signal)
107+
{
108+
const string PosixSignalRegistrationCreatedMessage = "PosixSignalRegistration created...";
109+
110+
var remoteInvokeOptions = new RemoteInvokeOptions { CheckExitCode = false };
111+
remoteInvokeOptions.StartInfo.RedirectStandardOutput = true;
112+
if (OperatingSystem.IsWindows())
113+
{
114+
remoteInvokeOptions.StartInfo.CreateNewProcessGroup = true;
115+
}
116+
117+
using RemoteInvokeHandle remoteHandle = RemoteExecutor.Invoke(
118+
(signalStr) =>
119+
{
120+
PosixSignal expectedSignal = Enum.Parse<PosixSignal>(signalStr);
121+
using ManualResetEvent receivedSignalEvent = new ManualResetEvent(false);
122+
ReEnableCtrlCHandlerIfNeeded(expectedSignal);
123+
124+
using PosixSignalRegistration p = PosixSignalRegistration.Create(expectedSignal, (ctx) =>
125+
{
126+
Assert.Equal(expectedSignal, ctx.Signal);
127+
receivedSignalEvent.Set();
128+
ctx.Cancel = true;
129+
});
130+
131+
Console.WriteLine(PosixSignalRegistrationCreatedMessage);
132+
133+
Assert.True(receivedSignalEvent.WaitOne(WaitInMS));
134+
135+
return 0;
136+
},
137+
arg: $"{signal}",
138+
remoteInvokeOptions);
139+
140+
while (!remoteHandle.Process.StandardOutput.ReadLine().EndsWith(PosixSignalRegistrationCreatedMessage))
141+
{
142+
Thread.Sleep(20);
143+
}
144+
145+
try
146+
{
147+
SendSignal(signal, remoteHandle.Process.Id);
148+
149+
Assert.True(remoteHandle.Process.WaitForExit(WaitInMS));
150+
Assert.Equal(0, remoteHandle.Process.ExitCode);
151+
}
152+
finally
153+
{
154+
// If sending the signal fails, we want to kill the process ASAP
155+
// to prevent RemoteExecutor's timeout from hiding it.
156+
remoteHandle.Process.Kill();
157+
}
158+
}
159+
83160
[ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
84161
[InlineData(-2)]
85162
[InlineData((long)int.MaxValue + 1)]

0 commit comments

Comments
 (0)