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 c2ef66f997772b..fcb8d0275b8261 100644 --- a/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs +++ b/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs @@ -16,6 +16,8 @@ public SafeProcessHandle(System.IntPtr existingHandle, bool ownsHandle) : base ( public void Kill() { } public int ProcessId { get { throw null; } } protected override bool ReleaseHandle() { throw null; } + [System.Runtime.Versioning.SupportedOSPlatformAttribute("windows")] + public void Resume() { } [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("ios")] [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("tvos")] [System.Runtime.Versioning.SupportedOSPlatformAttribute("maccatalyst")] @@ -354,6 +356,8 @@ public ProcessStartInfo(string fileName, System.Collections.Generic.IEnumerable< public Microsoft.Win32.SafeHandles.SafeFileHandle? StandardOutputHandle { get { throw null; } set { } } public bool StartDetached { get { throw null; } set { } } [System.Runtime.Versioning.SupportedOSPlatformAttribute("windows")] + public bool StartSuspended { get { throw null; } set { } } + [System.Runtime.Versioning.SupportedOSPlatformAttribute("windows")] public bool UseCredentialsForNetworkingOnly { get { throw null; } set { } } [System.Diagnostics.CodeAnalysis.AllowNullAttribute] public string UserName { get { throw null; } set { } } diff --git a/src/libraries/System.Diagnostics.Process/src/Microsoft/Win32/SafeHandles/SafeProcessHandle.Unix.cs b/src/libraries/System.Diagnostics.Process/src/Microsoft/Win32/SafeHandles/SafeProcessHandle.Unix.cs index 674b2b8821698c..cb56af29cd8aac 100644 --- a/src/libraries/System.Diagnostics.Process/src/Microsoft/Win32/SafeHandles/SafeProcessHandle.Unix.cs +++ b/src/libraries/System.Diagnostics.Process/src/Microsoft/Win32/SafeHandles/SafeProcessHandle.Unix.cs @@ -136,6 +136,11 @@ private bool SignalCore(PosixSignal signal) return true; } + private static void ResumeCore() + { + throw new PlatformNotSupportedException(); + } + private ProcessExitStatus WaitForExitCore() { ProcessWaitState waitState = GetWaitState(); diff --git a/src/libraries/System.Diagnostics.Process/src/Microsoft/Win32/SafeHandles/SafeProcessHandle.Windows.cs b/src/libraries/System.Diagnostics.Process/src/Microsoft/Win32/SafeHandles/SafeProcessHandle.Windows.cs index 478354c2393f73..7afcf6abc9ea62 100644 --- a/src/libraries/System.Diagnostics.Process/src/Microsoft/Win32/SafeHandles/SafeProcessHandle.Windows.cs +++ b/src/libraries/System.Diagnostics.Process/src/Microsoft/Win32/SafeHandles/SafeProcessHandle.Windows.cs @@ -22,6 +22,11 @@ public sealed partial class SafeProcessHandle : SafeHandleZeroOrMinusOneIsInvali // by the OS, which terminates all child processes in the job. private static readonly Lazy s_killOnParentExitJob = new(CreateKillOnParentExitJob); + // When the process was started with StartSuspended, this holds the main thread handle + // so that Resume() can call ResumeThread on it. The handle is closed after Resume() is called + // or when the SafeProcessHandle is disposed. + private IntPtr _mainThreadHandle; + /// /// Gets the process ID. /// @@ -48,6 +53,12 @@ public int ProcessId protected override bool ReleaseHandle() { + IntPtr threadHandle = Interlocked.Exchange(ref _mainThreadHandle, IntPtr.Zero); + if (threadHandle != IntPtr.Zero) + { + Interop.Kernel32.CloseHandle(threadHandle); + } + return Interop.Kernel32.CloseHandle(handle); } @@ -181,6 +192,8 @@ internal static unsafe SafeProcessHandle StartCore(ProcessStartInfo startInfo, S if (startInfo.CreateNoWindow) creationFlags |= Interop.Advapi32.StartupInfoOptions.CREATE_NO_WINDOW; if (startInfo.CreateNewProcessGroup) creationFlags |= Interop.Advapi32.StartupInfoOptions.CREATE_NEW_PROCESS_GROUP; if (startInfo.StartDetached) creationFlags |= Interop.Advapi32.StartupInfoOptions.DETACHED_PROCESS; + bool startSuspended = startInfo.StartSuspended; + if (startSuspended) creationFlags |= Interop.Advapi32.StartupInfoOptions.CREATE_SUSPENDED; // set up the environment block parameter string? environmentBlock = null; @@ -318,7 +331,16 @@ internal static unsafe SafeProcessHandle StartCore(ProcessStartInfo startInfo, S // assign it to the job object and then resume the thread. if (killOnParentExit && logon) { - AssignJobAndResumeThread(processInfo.hThread, procSH); + // Assign to the job. Resume the thread only if the user didn't request StartSuspended. + AssignJobAndResumeThread(processInfo.hThread, procSH, resume: !startSuspended); + } + + if (startSuspended && !IsInvalidHandle(processInfo.hThread)) + { + // Store the main thread handle so that Resume() can use it later. + // The handle will be closed either in Resume() or in ReleaseHandle(). + procSH._mainThreadHandle = processInfo.hThread; + processInfo.hThread = IntPtr.Zero; // Prevent the finally block from closing it. } } @@ -657,7 +679,7 @@ private static void DisableInheritanceAndRelease(SafeHandle?[] handlesToRelease) } } - private static void AssignJobAndResumeThread(IntPtr hThread, SafeProcessHandle procSH) + private static void AssignJobAndResumeThread(IntPtr hThread, SafeProcessHandle procSH, bool resume) { Debug.Assert(!IsInvalidHandle(hThread), "Thread handle must be valid for suspended process."); @@ -668,7 +690,7 @@ private static void AssignJobAndResumeThread(IntPtr hThread, SafeProcessHandle p throw new Win32Exception(Marshal.GetLastWin32Error()); } - if (Interop.Kernel32.ResumeThread(hThread) == 0xFFFFFFFF) + if (resume && Interop.Kernel32.ResumeThread(hThread) == 0xFFFFFFFF) { throw new Win32Exception(Marshal.GetLastWin32Error()); } @@ -681,6 +703,29 @@ private static void AssignJobAndResumeThread(IntPtr hThread, SafeProcessHandle p } } + private void ResumeCore() + { + Validate(); + + IntPtr threadHandle = Interlocked.Exchange(ref _mainThreadHandle, IntPtr.Zero); + if (threadHandle == IntPtr.Zero) + { + throw new InvalidOperationException(SR.ProcessNotStartedSuspended); + } + + try + { + if (Interop.Kernel32.ResumeThread(threadHandle) == 0xFFFFFFFF) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + finally + { + Interop.Kernel32.CloseHandle(threadHandle); + } + } + private ProcessExitStatus WaitForExitCore() { using Interop.Kernel32.ProcessWaitHandle processWaitHandle = new(this); diff --git a/src/libraries/System.Diagnostics.Process/src/Microsoft/Win32/SafeHandles/SafeProcessHandle.cs b/src/libraries/System.Diagnostics.Process/src/Microsoft/Win32/SafeHandles/SafeProcessHandle.cs index bc5fbdcfbad117..420b1c3e0106f1 100644 --- a/src/libraries/System.Diagnostics.Process/src/Microsoft/Win32/SafeHandles/SafeProcessHandle.cs +++ b/src/libraries/System.Diagnostics.Process/src/Microsoft/Win32/SafeHandles/SafeProcessHandle.cs @@ -205,6 +205,19 @@ internal static SafeProcessHandle Start(ProcessStartInfo startInfo, bool fallbac return StartCore(startInfo, childInputHandle, childOutputHandle, childErrorHandle, inheritedHandles); } + /// + /// Resumes the process that was started with set to . + /// + /// + /// This method can only be called once. After the process has been resumed, calling this method again + /// throws . + /// + /// The process was not started with set to , or has already been resumed. + /// The current operating system is not Windows. + /// The thread could not be resumed. + [SupportedOSPlatform("windows")] + public void Resume() => ResumeCore(); + /// /// Sends a request to the OS to terminate the process. /// diff --git a/src/libraries/System.Diagnostics.Process/src/Resources/Strings.resx b/src/libraries/System.Diagnostics.Process/src/Resources/Strings.resx index cddc41e09c9eeb..2068e591316392 100644 --- a/src/libraries/System.Diagnostics.Process/src/Resources/Strings.resx +++ b/src/libraries/System.Diagnostics.Process/src/Resources/Strings.resx @@ -231,6 +231,12 @@ The StartDetached property cannot be used with UseShellExecute set to true. + + The StartSuspended property cannot be used with UseShellExecute set to true. + + + Resume can only be called on a process that was started with StartSuspended set to true and has not been resumed yet. + The FileName property should not be a directory unless UseShellExecute is set. diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.cs index 99bd984a3cbe28..efab30faa557b6 100644 --- a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.cs +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.cs @@ -143,6 +143,27 @@ public string Arguments /// public bool StartDetached { get; set; } + /// + /// Gets or sets a value indicating whether the process should be started in a suspended state. + /// + /// if the process should be started suspended; otherwise, . The default is . + /// + /// + /// When set to , the process is created with its main thread suspended. + /// The process will not begin execution until is called + /// on the returned by . + /// + /// + /// On Windows, the process is started with the + /// CREATE_SUSPENDED flag. + /// + /// + /// This property cannot be used together with set to . + /// + /// + [SupportedOSPlatform("windows")] + public bool StartSuspended { get; set; } + /// /// Gets or sets a that will be used as the standard input of the child process. /// When set, the handle is passed directly to the child process and must be . @@ -448,6 +469,11 @@ internal void ThrowIfInvalid(out bool anyRedirection, out SafeHandle[]? inherite throw new InvalidOperationException(SR.StartDetachedNotCompatible); } + if (OperatingSystem.IsWindows() && StartSuspended && UseShellExecute) + { + throw new InvalidOperationException(SR.StartSuspendedNotCompatible); + } + if (InheritedHandles is not null && (UseShellExecute || !string.IsNullOrEmpty(UserName))) { throw new InvalidOperationException(SR.InheritedHandlesRequiresCreateProcess); diff --git a/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs b/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs new file mode 100644 index 00000000000000..1b5cbaeab55a33 --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs @@ -0,0 +1,204 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.Win32.SafeHandles; +using Xunit; + +namespace System.Diagnostics.Tests +{ + [ConditionalClass(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [PlatformSpecific(TestPlatforms.Windows)] + public class StartSuspendedTests : ProcessTestBase + { + [ConditionalFact] + public void StartSuspended_ResumeCompletes() + { + Process process = CreateProcess(static () => RemoteExecutor.SuccessExitCode); + process.StartInfo.StartSuspended = true; + + using SafeProcessHandle processHandle = SafeProcessHandle.Start(process.StartInfo); + + // The process should not have exited yet because it is suspended. + bool hasExited = processHandle.TryWaitForExit(TimeSpan.FromMilliseconds(200), out _); + Assert.False(hasExited, "Suspended process should not have exited yet."); + + processHandle.Resume(); + + ProcessExitStatus exitStatus = processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromMilliseconds(WaitInMS)); + Assert.Equal(RemoteExecutor.SuccessExitCode, exitStatus.ExitCode); + } + + [ConditionalFact] + public void StartSuspended_ProcessIdIsValid() + { + Process process = CreateProcess(static () => RemoteExecutor.SuccessExitCode); + process.StartInfo.StartSuspended = true; + + using SafeProcessHandle processHandle = SafeProcessHandle.Start(process.StartInfo); + + try + { + Assert.NotEqual(0, processHandle.ProcessId); + } + finally + { + processHandle.Resume(); + processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromMilliseconds(WaitInMS)); + } + } + + [ConditionalFact] + public void Resume_CalledTwice_ThrowsInvalidOperationException() + { + Process process = CreateProcess(static () => RemoteExecutor.SuccessExitCode); + process.StartInfo.StartSuspended = true; + + using SafeProcessHandle processHandle = SafeProcessHandle.Start(process.StartInfo); + + processHandle.Resume(); + + Assert.Throws(() => processHandle.Resume()); + + processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromMilliseconds(WaitInMS)); + } + + [ConditionalFact] + public void Resume_OnNonSuspendedProcess_ThrowsInvalidOperationException() + { + Process process = CreateProcess(static () => RemoteExecutor.SuccessExitCode); + + using SafeProcessHandle processHandle = SafeProcessHandle.Start(process.StartInfo); + + Assert.Throws(() => processHandle.Resume()); + + processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromMilliseconds(WaitInMS)); + } + + [ConditionalFact] + public void StartSuspended_KillWithoutResume_Succeeds() + { + Process process = CreateProcess(static () => + { + Thread.Sleep(Timeout.Infinite); + return RemoteExecutor.SuccessExitCode; + }); + process.StartInfo.StartSuspended = true; + + using SafeProcessHandle processHandle = SafeProcessHandle.Start(process.StartInfo); + + // Kill the suspended process without resuming it first. + processHandle.Kill(); + + ProcessExitStatus exitStatus = processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromMilliseconds(WaitInMS)); + Assert.NotEqual(0, exitStatus.ExitCode); + } + + [ConditionalFact] + public void StartSuspended_DisposeWithoutResume_DoesNotThrow() + { + Process process = CreateProcess(static () => + { + Thread.Sleep(Timeout.Infinite); + return RemoteExecutor.SuccessExitCode; + }); + process.StartInfo.StartSuspended = true; + + SafeProcessHandle processHandle = SafeProcessHandle.Start(process.StartInfo); + int processId = processHandle.ProcessId; + + // Dispose without resuming should not throw. + processHandle.Dispose(); + + // Clean up the orphaned process. + Assert.True(Process.TryGetProcessById(processId, out Process? orphaned)); + + using (orphaned) + { + orphaned.Kill(); + orphaned.WaitForExit(WaitInMS); + } + } + + [ConditionalFact] + public void StartSuspended_WithUseShellExecute_ThrowsInvalidOperationException() + { + ProcessStartInfo startInfo = new("cmd") + { + StartSuspended = true, + UseShellExecute = true, + }; + + Assert.Throws(() => SafeProcessHandle.Start(startInfo)); + } + + [ConditionalFact] + public void StartSuspended_PropertyDefaultsToFalse() + { + ProcessStartInfo startInfo = new(); + Assert.False(startInfo.StartSuspended); + } + + [ConditionalFact] + public void StartSuspended_CanSetAndGet() + { + ProcessStartInfo startInfo = new() + { + StartSuspended = true, + }; + + Assert.True(startInfo.StartSuspended); + } + + [ConditionalFact] + public async Task StartSuspended_WithPipeRedirection_Works() + { + Process process = CreateProcess(static () => + { + Console.Write("hello"); + return RemoteExecutor.SuccessExitCode; + }); + + SafeFileHandle.CreateAnonymousPipe(out SafeFileHandle outputReadPipe, out SafeFileHandle outputWritePipe); + + using (outputReadPipe) + using (outputWritePipe) + { + process.StartInfo.StandardOutputHandle = outputWritePipe; + process.StartInfo.StartSuspended = true; + + using SafeProcessHandle processHandle = SafeProcessHandle.Start(process.StartInfo); + outputWritePipe.Close(); + + // Verify nothing has been written yet while the process is suspended. + using FileStream readStream = new(outputReadPipe, FileAccess.Read, bufferSize: 1, outputReadPipe.IsAsync); + byte[] buffer = new byte[10]; + Task readTask = readStream.ReadAsync(buffer).AsTask(); + Assert.NotSame(readTask, await Task.WhenAny(readTask, Task.Delay(50))); + processHandle.Resume(); + + int bytesRead = await readTask; + string content = Encoding.UTF8.GetString(buffer, 0, bytesRead); + Assert.Equal("hello", content); + + ProcessExitStatus exitStatus = processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromMilliseconds(WaitInMS)); + Assert.Equal(RemoteExecutor.SuccessExitCode, exitStatus.ExitCode); + } + } + } + + public class StartSuspendedTests_NonWindows : ProcessTestBase + { + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Resume throws PlatformNotSupportedException on non-Windows")] + public void Resume_OnNonWindows_ThrowsPlatformNotSupportedException() + { + using SafeProcessHandle handle = new(); + Assert.Throws(() => handle.Resume()); + } + } +} 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 33132969ff9ec7..f5492610e51f15 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 @@ -43,6 +43,7 @@ +