From 6a6c94a9394c1b70784dfd68c4a5cf731bc22ef2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:45:32 +0000 Subject: [PATCH 01/10] Implement StartSuspended and Resume APIs for Windows Add ProcessStartInfo.StartSuspended property and SafeProcessHandle.Resume() method. On Windows, creates the process with CREATE_SUSPENDED flag and stores the main thread handle for later resumption via ResumeThread. On non-Windows, Resume() throws PlatformNotSupportedException. Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com> --- .../ref/System.Diagnostics.Process.cs | 4 + .../SafeHandles/SafeProcessHandle.Unix.cs | 5 + .../SafeHandles/SafeProcessHandle.Windows.cs | 72 ++++++- .../Win32/SafeHandles/SafeProcessHandle.cs | 22 ++ .../src/Resources/Strings.resx | 6 + .../System/Diagnostics/ProcessStartInfo.cs | 28 +++ .../tests/StartSuspendedTests.cs | 201 ++++++++++++++++++ 7 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs 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..01083ba1612481 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 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..a8395e9cb07671 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,24 @@ 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); + if (startSuspended) + { + // The user wants the process to stay suspended. + // Assign to the job but don't resume - the caller will call Resume() later. + AssignJobOnly(processInfo.hThread, procSH); + } + else + { + AssignJobAndResumeThread(processInfo.hThread, procSH); + } + } + + 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. } } @@ -681,6 +711,46 @@ private static void AssignJobAndResumeThread(IntPtr hThread, SafeProcessHandle p } } + private static void AssignJobOnly(IntPtr hThread, SafeProcessHandle procSH) + { + Debug.Assert(!IsInvalidHandle(hThread), "Thread handle must be valid for suspended process."); + + try + { + if (!Interop.Kernel32.AssignProcessToJobObject(s_killOnParentExitJob.Value, procSH)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + catch + { + // If we fail to assign to the job, terminate the process. + Interop.Kernel32.TerminateProcess(procSH, -1); + throw; + } + } + + private void ResumeCore() + { + 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..95adba2da06519 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,28 @@ internal static SafeProcessHandle Start(ProcessStartInfo startInfo, bool fallbac return StartCore(startInfo, childInputHandle, childOutputHandle, childErrorHandle, inheritedHandles); } + /// + /// Resumes the main thread of a 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 . + /// + /// + /// If is never called, the suspended main thread handle is closed when this + /// is disposed, but the process itself is not terminated automatically. + /// + /// + /// 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..ce44e9c253aa44 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,13 @@ internal void ThrowIfInvalid(out bool anyRedirection, out SafeHandle[]? inherite throw new InvalidOperationException(SR.StartDetachedNotCompatible); } +#pragma warning disable CA1416 // StartSuspended getter works on all platforms; the attribute guards the actual effect + if (StartSuspended && UseShellExecute) +#pragma warning restore CA1416 + { + 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..a5652dea475c77 --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs @@ -0,0 +1,201 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; +using System.Threading; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.Win32.SafeHandles; +using Xunit; + +namespace System.Diagnostics.Tests +{ + public class StartSuspendedTests : ProcessTestBase + { + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [PlatformSpecific(TestPlatforms.Windows)] + 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.FromSeconds(30)); + Assert.Equal(RemoteExecutor.SuccessExitCode, exitStatus.ExitCode); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [PlatformSpecific(TestPlatforms.Windows)] + 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.FromSeconds(30)); + } + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [PlatformSpecific(TestPlatforms.Windows)] + 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.FromSeconds(30)); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [PlatformSpecific(TestPlatforms.Windows)] + 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.FromSeconds(30)); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [PlatformSpecific(TestPlatforms.Windows)] + 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.FromSeconds(30)); + Assert.NotEqual(0, exitStatus.ExitCode); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [PlatformSpecific(TestPlatforms.Windows)] + 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. + try + { + using Process orphaned = Process.GetProcessById(processId); + orphaned.Kill(); + orphaned.WaitForExit(WaitInMS); + } + catch (ArgumentException) { } // Process may have already exited. + catch (InvalidOperationException) { } + } + + [Fact] + [PlatformSpecific(TestPlatforms.Windows)] + public void StartSuspended_WithUseShellExecute_ThrowsInvalidOperationException() + { + ProcessStartInfo startInfo = new("cmd") + { + StartSuspended = true, + UseShellExecute = true, + }; + + Assert.Throws(() => SafeProcessHandle.Start(startInfo)); + } + + [Fact] + public void StartSuspended_PropertyDefaultsToFalse() + { + ProcessStartInfo startInfo = new(); + Assert.False(startInfo.StartSuspended); + } + + [Fact] + public void StartSuspended_CanSetAndGet() + { + ProcessStartInfo startInfo = new() + { + StartSuspended = true, + }; + + Assert.True(startInfo.StartSuspended); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Windows, "Resume throws PlatformNotSupportedException on non-Windows")] + public void Resume_OnNonWindows_ThrowsPlatformNotSupportedException() + { + using SafeProcessHandle handle = new(); + Assert.Throws(() => handle.Resume()); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [PlatformSpecific(TestPlatforms.Windows)] + public void StartSuspended_WithPipeRedirection_Works() + { + Process process = CreateProcess(static () => + { + Console.WriteLine("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(); + + // Process is suspended, should not have written anything yet. + processHandle.Resume(); + + using System.IO.StreamReader streamReader = new(new System.IO.FileStream(outputReadPipe, System.IO.FileAccess.Read, bufferSize: 1, outputReadPipe.IsAsync)); + Assert.Equal("hello", streamReader.ReadLine()); + + ProcessExitStatus exitStatus = processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromSeconds(30)); + Assert.Equal(RemoteExecutor.SuccessExitCode, exitStatus.ExitCode); + } + } + } +} From 7e68781de526d40f69c57ed25b0818dcffaef413 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:09:52 +0000 Subject: [PATCH 02/10] Add StartSuspendedTests.cs to test project, fix CA1822 on Unix ResumeCore Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com> --- .../src/Microsoft/Win32/SafeHandles/SafeProcessHandle.Unix.cs | 2 +- .../tests/System.Diagnostics.Process.Tests.csproj | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 01083ba1612481..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,7 +136,7 @@ private bool SignalCore(PosixSignal signal) return true; } - private void ResumeCore() + private static void ResumeCore() { throw new PlatformNotSupportedException(); } 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 @@ + From 33456c2ba2b82f274d24bf10f909641ccdd33f3c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:11:24 +0000 Subject: [PATCH 03/10] Address code review: fix FQN types in tests, add System.IO using Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com> --- .../System.Diagnostics.Process/tests/StartSuspendedTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs b/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs index a5652dea475c77..722a737fa1d06f 100644 --- a/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs +++ b/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Runtime.InteropServices; +using System.IO; using System.Threading; using Microsoft.DotNet.RemoteExecutor; using Microsoft.Win32.SafeHandles; @@ -190,7 +190,7 @@ public void StartSuspended_WithPipeRedirection_Works() // Process is suspended, should not have written anything yet. processHandle.Resume(); - using System.IO.StreamReader streamReader = new(new System.IO.FileStream(outputReadPipe, System.IO.FileAccess.Read, bufferSize: 1, outputReadPipe.IsAsync)); + using StreamReader streamReader = new(new FileStream(outputReadPipe, FileAccess.Read, bufferSize: 1, outputReadPipe.IsAsync)); Assert.Equal("hello", streamReader.ReadLine()); ProcessExitStatus exitStatus = processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromSeconds(30)); From fc6d6b01c84d1e941550b739f5556d84648540c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:39:22 +0000 Subject: [PATCH 04/10] Address reviewer feedback: docs, expression body, merge methods, test cleanup Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com> --- .../SafeHandles/SafeProcessHandle.Windows.cs | 35 +------- .../Win32/SafeHandles/SafeProcessHandle.cs | 13 +-- .../tests/StartSuspendedTests.cs | 83 ++++++++++--------- 3 files changed, 50 insertions(+), 81 deletions(-) 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 a8395e9cb07671..c73443e95081d5 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 @@ -331,16 +331,8 @@ internal static unsafe SafeProcessHandle StartCore(ProcessStartInfo startInfo, S // assign it to the job object and then resume the thread. if (killOnParentExit && logon) { - if (startSuspended) - { - // The user wants the process to stay suspended. - // Assign to the job but don't resume - the caller will call Resume() later. - AssignJobOnly(processInfo.hThread, procSH); - } - else - { - 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)) @@ -687,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."); @@ -698,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()); } @@ -711,25 +703,6 @@ private static void AssignJobAndResumeThread(IntPtr hThread, SafeProcessHandle p } } - private static void AssignJobOnly(IntPtr hThread, SafeProcessHandle procSH) - { - Debug.Assert(!IsInvalidHandle(hThread), "Thread handle must be valid for suspended process."); - - try - { - if (!Interop.Kernel32.AssignProcessToJobObject(s_killOnParentExitJob.Value, procSH)) - { - throw new Win32Exception(Marshal.GetLastWin32Error()); - } - } - catch - { - // If we fail to assign to the job, terminate the process. - Interop.Kernel32.TerminateProcess(procSH, -1); - throw; - } - } - private void ResumeCore() { IntPtr threadHandle = Interlocked.Exchange(ref _mainThreadHandle, IntPtr.Zero); 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 95adba2da06519..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 @@ -206,26 +206,17 @@ internal static SafeProcessHandle Start(ProcessStartInfo startInfo, bool fallbac } /// - /// Resumes the main thread of a process that was started with set to . + /// 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 . - /// - /// - /// If is never called, the suspended main thread handle is closed when this - /// is disposed, but the process itself is not terminated automatically. - /// /// /// 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(); - } + public void Resume() => ResumeCore(); /// /// Sends a request to the OS to terminate the process. diff --git a/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs b/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs index 722a737fa1d06f..4667dcc3558403 100644 --- a/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs +++ b/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs @@ -3,16 +3,18 @@ using System.IO; 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(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] - [PlatformSpecific(TestPlatforms.Windows)] + [ConditionalFact] public void StartSuspended_ResumeCompletes() { Process process = CreateProcess(static () => RemoteExecutor.SuccessExitCode); @@ -26,12 +28,11 @@ public void StartSuspended_ResumeCompletes() processHandle.Resume(); - ProcessExitStatus exitStatus = processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromSeconds(30)); + ProcessExitStatus exitStatus = processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromMilliseconds(WaitInMS)); Assert.Equal(RemoteExecutor.SuccessExitCode, exitStatus.ExitCode); } - [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] - [PlatformSpecific(TestPlatforms.Windows)] + [ConditionalFact] public void StartSuspended_ProcessIdIsValid() { Process process = CreateProcess(static () => RemoteExecutor.SuccessExitCode); @@ -46,12 +47,11 @@ public void StartSuspended_ProcessIdIsValid() finally { processHandle.Resume(); - processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromSeconds(30)); + processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromMilliseconds(WaitInMS)); } } - [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] - [PlatformSpecific(TestPlatforms.Windows)] + [ConditionalFact] public void Resume_CalledTwice_ThrowsInvalidOperationException() { Process process = CreateProcess(static () => RemoteExecutor.SuccessExitCode); @@ -63,11 +63,10 @@ public void Resume_CalledTwice_ThrowsInvalidOperationException() Assert.Throws(() => processHandle.Resume()); - processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromSeconds(30)); + processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromMilliseconds(WaitInMS)); } - [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] - [PlatformSpecific(TestPlatforms.Windows)] + [ConditionalFact] public void Resume_OnNonSuspendedProcess_ThrowsInvalidOperationException() { Process process = CreateProcess(static () => RemoteExecutor.SuccessExitCode); @@ -76,11 +75,10 @@ public void Resume_OnNonSuspendedProcess_ThrowsInvalidOperationException() Assert.Throws(() => processHandle.Resume()); - processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromSeconds(30)); + processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromMilliseconds(WaitInMS)); } - [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] - [PlatformSpecific(TestPlatforms.Windows)] + [ConditionalFact] public void StartSuspended_KillWithoutResume_Succeeds() { Process process = CreateProcess(static () => @@ -95,12 +93,11 @@ public void StartSuspended_KillWithoutResume_Succeeds() // Kill the suspended process without resuming it first. processHandle.Kill(); - ProcessExitStatus exitStatus = processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromSeconds(30)); + ProcessExitStatus exitStatus = processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromMilliseconds(WaitInMS)); Assert.NotEqual(0, exitStatus.ExitCode); } - [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] - [PlatformSpecific(TestPlatforms.Windows)] + [ConditionalFact] public void StartSuspended_DisposeWithoutResume_DoesNotThrow() { Process process = CreateProcess(static () => @@ -117,18 +114,16 @@ public void StartSuspended_DisposeWithoutResume_DoesNotThrow() processHandle.Dispose(); // Clean up the orphaned process. - try + Assert.True(Process.TryGetProcessById(processId, out Process? orphaned)); + + using (orphaned) { - using Process orphaned = Process.GetProcessById(processId); orphaned.Kill(); orphaned.WaitForExit(WaitInMS); } - catch (ArgumentException) { } // Process may have already exited. - catch (InvalidOperationException) { } } - [Fact] - [PlatformSpecific(TestPlatforms.Windows)] + [ConditionalFact] public void StartSuspended_WithUseShellExecute_ThrowsInvalidOperationException() { ProcessStartInfo startInfo = new("cmd") @@ -140,14 +135,14 @@ public void StartSuspended_WithUseShellExecute_ThrowsInvalidOperationException() Assert.Throws(() => SafeProcessHandle.Start(startInfo)); } - [Fact] + [ConditionalFact] public void StartSuspended_PropertyDefaultsToFalse() { ProcessStartInfo startInfo = new(); Assert.False(startInfo.StartSuspended); } - [Fact] + [ConditionalFact] public void StartSuspended_CanSetAndGet() { ProcessStartInfo startInfo = new() @@ -158,16 +153,7 @@ public void StartSuspended_CanSetAndGet() Assert.True(startInfo.StartSuspended); } - [Fact] - [SkipOnPlatform(TestPlatforms.Windows, "Resume throws PlatformNotSupportedException on non-Windows")] - public void Resume_OnNonWindows_ThrowsPlatformNotSupportedException() - { - using SafeProcessHandle handle = new(); - Assert.Throws(() => handle.Resume()); - } - - [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] - [PlatformSpecific(TestPlatforms.Windows)] + [ConditionalFact] public void StartSuspended_WithPipeRedirection_Works() { Process process = CreateProcess(static () => @@ -187,15 +173,34 @@ public void StartSuspended_WithPipeRedirection_Works() using SafeProcessHandle processHandle = SafeProcessHandle.Start(process.StartInfo); outputWritePipe.Close(); - // Process is suspended, should not have written anything yet. + // 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[1]; + Task readTask = readStream.ReadAsync(buffer, 0, 1); + Assert.False(readTask.Wait(50), "Suspended process should not have written any output yet."); + processHandle.Resume(); - using StreamReader streamReader = new(new FileStream(outputReadPipe, FileAccess.Read, bufferSize: 1, outputReadPipe.IsAsync)); - Assert.Equal("hello", streamReader.ReadLine()); + using StreamReader streamReader = new(readStream); + // The first byte was already read into the buffer by the async read. + string firstChar = System.Text.Encoding.UTF8.GetString(buffer, 0, readTask.GetAwaiter().GetResult()); + string? rest = streamReader.ReadLine(); + Assert.Equal("hello", firstChar + rest); - ProcessExitStatus exitStatus = processHandle.WaitForExitOrKillOnTimeout(TimeSpan.FromSeconds(30)); + 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()); + } + } } From 1701cd7578c7768c36ca95789eb276f6267b2f6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:40:09 +0000 Subject: [PATCH 05/10] Add using System.Text and remove fully qualified namespace Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com> --- .../System.Diagnostics.Process/tests/StartSuspendedTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs b/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs index 4667dcc3558403..505929c259dadc 100644 --- a/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs +++ b/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs @@ -2,6 +2,7 @@ // 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; @@ -183,7 +184,7 @@ public void StartSuspended_WithPipeRedirection_Works() using StreamReader streamReader = new(readStream); // The first byte was already read into the buffer by the async read. - string firstChar = System.Text.Encoding.UTF8.GetString(buffer, 0, readTask.GetAwaiter().GetResult()); + string firstChar = Encoding.UTF8.GetString(buffer, 0, readTask.GetAwaiter().GetResult()); string? rest = streamReader.ReadLine(); Assert.Equal("hello", firstChar + rest); From 5b9e1429bc11517426ebae8eab4b74860222d525 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:11:46 +0000 Subject: [PATCH 06/10] Address pipe test feedback: async Task, Console.Write, read full buffer, await Co-authored-by: adamsitnik <6011991+adamsitnik@users.noreply.github.com> --- .../tests/StartSuspendedTests.cs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs b/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs index 505929c259dadc..395515fc43b55b 100644 --- a/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs +++ b/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs @@ -155,11 +155,11 @@ public void StartSuspended_CanSetAndGet() } [ConditionalFact] - public void StartSuspended_WithPipeRedirection_Works() + public async Task StartSuspended_WithPipeRedirection_Works() { Process process = CreateProcess(static () => { - Console.WriteLine("hello"); + Console.Write("hello"); return RemoteExecutor.SuccessExitCode; }); @@ -176,17 +176,15 @@ public void StartSuspended_WithPipeRedirection_Works() // 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[1]; - Task readTask = readStream.ReadAsync(buffer, 0, 1); + byte[] buffer = new byte[10]; + Task readTask = readStream.ReadAsync(buffer); Assert.False(readTask.Wait(50), "Suspended process should not have written any output yet."); processHandle.Resume(); - using StreamReader streamReader = new(readStream); - // The first byte was already read into the buffer by the async read. - string firstChar = Encoding.UTF8.GetString(buffer, 0, readTask.GetAwaiter().GetResult()); - string? rest = streamReader.ReadLine(); - Assert.Equal("hello", firstChar + rest); + 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); From c28caf83c4739312032bb29f18a1fca609d44708 Mon Sep 17 00:00:00 2001 From: Adam Sitnik Date: Wed, 17 Jun 2026 15:17:39 +0200 Subject: [PATCH 07/10] fix the build --- .../System.Diagnostics.Process/tests/StartSuspendedTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs b/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs index 395515fc43b55b..882670eec946bb 100644 --- a/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs +++ b/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs @@ -177,7 +177,7 @@ public async Task StartSuspended_WithPipeRedirection_Works() // 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); + Task readTask = readStream.ReadAsync(buffer).AsTask(); Assert.False(readTask.Wait(50), "Suspended process should not have written any output yet."); processHandle.Resume(); From 3ceae161ce8211f64e64f03f7c1cfeb13ad1c208 Mon Sep 17 00:00:00 2001 From: Adam Sitnik Date: Wed, 17 Jun 2026 15:30:59 +0200 Subject: [PATCH 08/10] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/Microsoft/Win32/SafeHandles/SafeProcessHandle.Unix.cs | 2 +- .../System.Diagnostics.Process/tests/StartSuspendedTests.cs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) 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 cb56af29cd8aac..01083ba1612481 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,7 +136,7 @@ private bool SignalCore(PosixSignal signal) return true; } - private static void ResumeCore() + private void ResumeCore() { throw new PlatformNotSupportedException(); } diff --git a/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs b/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs index 882670eec946bb..1b5cbaeab55a33 100644 --- a/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs +++ b/src/libraries/System.Diagnostics.Process/tests/StartSuspendedTests.cs @@ -178,8 +178,7 @@ public async Task StartSuspended_WithPipeRedirection_Works() using FileStream readStream = new(outputReadPipe, FileAccess.Read, bufferSize: 1, outputReadPipe.IsAsync); byte[] buffer = new byte[10]; Task readTask = readStream.ReadAsync(buffer).AsTask(); - Assert.False(readTask.Wait(50), "Suspended process should not have written any output yet."); - + Assert.NotSame(readTask, await Task.WhenAny(readTask, Task.Delay(50))); processHandle.Resume(); int bytesRead = await readTask; From d8306913c58c28c172806c6a10fb9fe1fa624002 Mon Sep 17 00:00:00 2001 From: Adam Sitnik Date: Wed, 17 Jun 2026 15:34:04 +0200 Subject: [PATCH 09/10] fix the build... --- .../src/Microsoft/Win32/SafeHandles/SafeProcessHandle.Unix.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 01083ba1612481..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,7 +136,7 @@ private bool SignalCore(PosixSignal signal) return true; } - private void ResumeCore() + private static void ResumeCore() { throw new PlatformNotSupportedException(); } From 5cd8a2649722d02d9780254c286c1bf0cdccb7b2 Mon Sep 17 00:00:00 2001 From: Adam Sitnik Date: Wed, 17 Jun 2026 15:38:18 +0200 Subject: [PATCH 10/10] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Microsoft/Win32/SafeHandles/SafeProcessHandle.Windows.cs | 2 ++ .../src/System/Diagnostics/ProcessStartInfo.cs | 4 +--- 2 files changed, 3 insertions(+), 3 deletions(-) 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 c73443e95081d5..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 @@ -705,6 +705,8 @@ private static void AssignJobAndResumeThread(IntPtr hThread, SafeProcessHandle p private void ResumeCore() { + Validate(); + IntPtr threadHandle = Interlocked.Exchange(ref _mainThreadHandle, IntPtr.Zero); if (threadHandle == IntPtr.Zero) { 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 ce44e9c253aa44..efab30faa557b6 100644 --- a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.cs +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/ProcessStartInfo.cs @@ -469,9 +469,7 @@ internal void ThrowIfInvalid(out bool anyRedirection, out SafeHandle[]? inherite throw new InvalidOperationException(SR.StartDetachedNotCompatible); } -#pragma warning disable CA1416 // StartSuspended getter works on all platforms; the attribute guards the actual effect - if (StartSuspended && UseShellExecute) -#pragma warning restore CA1416 + if (OperatingSystem.IsWindows() && StartSuspended && UseShellExecute) { throw new InvalidOperationException(SR.StartSuspendedNotCompatible); }