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 7257744b6de56c..0a6e75ee3cba55 100644 --- a/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs +++ b/src/libraries/System.Diagnostics.Process/ref/System.Diagnostics.Process.cs @@ -157,6 +157,8 @@ public void Kill() { } public void Kill(bool entireProcessTree) { } public static void LeaveDebugMode() { } protected void OnExited() { } + public (byte[] StandardOutput, byte[] StandardError) ReadAllBytes(System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } + public (string StandardOutput, string StandardError) ReadAllText(System.TimeSpan? timeout = default(System.TimeSpan?)) { throw null; } public void Refresh() { } [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("ios")] [System.Runtime.Versioning.UnsupportedOSPlatformAttribute("tvos")] 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 2c1c9e0be2eb23..6db2a72aeb08fa 100644 --- a/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj +++ b/src/libraries/System.Diagnostics.Process/src/System.Diagnostics.Process.csproj @@ -20,6 +20,7 @@ + @@ -224,6 +225,13 @@ + + + + @@ -236,6 +244,7 @@ + @@ -246,6 +255,14 @@ Link="Common\Interop\Unix\Interop.Libraries.cs" /> + + + + + diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Multiplexing.Unix.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Multiplexing.Unix.cs new file mode 100644 index 00000000000000..f23ad6631fa89e --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Multiplexing.Unix.cs @@ -0,0 +1,153 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace System.Diagnostics +{ + public partial class Process + { + /// + /// Reads from both standard output and standard error pipes using Unix poll-based multiplexing + /// with non-blocking reads. + /// + private static void ReadPipes( + SafeFileHandle outputHandle, + SafeFileHandle errorHandle, + int timeoutMs, + ref byte[] outputBuffer, + ref int outputBytesRead, + ref byte[] errorBuffer, + ref int errorBytesRead) + { + int outputFd = outputHandle.DangerousGetHandle().ToInt32(); + int errorFd = errorHandle.DangerousGetHandle().ToInt32(); + + if (Interop.Sys.Fcntl.DangerousSetIsNonBlocking((IntPtr)outputFd, 1) != 0 || Interop.Sys.Fcntl.DangerousSetIsNonBlocking((IntPtr)errorFd, 1) != 0) + { + throw new Win32Exception(); + } + + Span pollFds = stackalloc Interop.PollEvent[2]; + + long deadline = timeoutMs >= 0 + ? Environment.TickCount64 + timeoutMs + : long.MaxValue; + + bool outputDone = false, errorDone = false; + while (!outputDone || !errorDone) + { + int numFds = 0; + + int outputIndex = -1; + int errorIndex = -1; + + if (!outputDone) + { + outputIndex = numFds; + pollFds[numFds].FileDescriptor = outputFd; + pollFds[numFds].Events = Interop.PollEvents.POLLIN; + pollFds[numFds].TriggeredEvents = Interop.PollEvents.POLLNONE; + numFds++; + } + + if (!errorDone) + { + errorIndex = numFds; + pollFds[numFds].FileDescriptor = errorFd; + pollFds[numFds].Events = Interop.PollEvents.POLLIN; + pollFds[numFds].TriggeredEvents = Interop.PollEvents.POLLNONE; + numFds++; + } + + int pollTimeout; + if (!TryGetRemainingTimeout(deadline, timeoutMs, out pollTimeout)) + { + throw new TimeoutException(); + } + + unsafe + { + uint triggered; + fixed (Interop.PollEvent* pPollFds = pollFds) + { + Interop.Error error = Interop.Sys.Poll(pPollFds, (uint)numFds, pollTimeout, &triggered); + if (error != Interop.Error.SUCCESS) + { + if (error == Interop.Error.EINTR) + { + // We don't re-issue the poll immediately because we need to check + // if we've already exceeded the overall timeout. + continue; + } + + throw new Win32Exception(Interop.Sys.ConvertErrorPalToPlatform(error)); + } + + if (triggered == 0) + { + throw new TimeoutException(); + } + } + } + + for (int i = 0; i < numFds; i++) + { + if (pollFds[i].TriggeredEvents == Interop.PollEvents.POLLNONE) + { + continue; + } + + bool isError = i == errorIndex; + SafeFileHandle currentHandle = isError ? errorHandle : outputHandle; + ref byte[] currentBuffer = ref (isError ? ref errorBuffer : ref outputBuffer); + ref int currentBytesRead = ref (isError ? ref errorBytesRead : ref outputBytesRead); + ref bool currentDone = ref (isError ? ref errorDone : ref outputDone); + + int bytesRead = ReadNonBlocking(currentHandle, currentBuffer, currentBytesRead); + if (bytesRead > 0) + { + currentBytesRead += bytesRead; + + if (currentBytesRead == currentBuffer.Length) + { + RentLargerBuffer(ref currentBuffer, currentBytesRead); + } + } + else if (bytesRead == 0) + { + // EOF: pipe write end was closed. + currentDone = true; + } + // bytesRead < 0 means EAGAIN — nothing available yet, let poll retry. + } + } + } + + /// + /// Performs a non-blocking read from the given handle into the buffer starting at the specified offset. + /// Returns the number of bytes read, 0 for EOF, or -1 for EAGAIN (nothing available yet). + /// + private static unsafe int ReadNonBlocking(SafeFileHandle handle, byte[] buffer, int offset) + { + fixed (byte* pBuffer = buffer) + { + int bytesRead = Interop.Sys.Read(handle, pBuffer + offset, buffer.Length - offset); + if (bytesRead < 0) + { + Interop.ErrorInfo errorInfo = Interop.Sys.GetLastErrorInfo(); + if (errorInfo.Error == Interop.Error.EAGAIN) + { + return -1; + } + + throw new Win32Exception(errorInfo.RawErrno); + } + + return bytesRead; + } + } + } +} diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Multiplexing.Windows.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Multiplexing.Windows.cs new file mode 100644 index 00000000000000..6d28e278a28bac --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Multiplexing.Windows.cs @@ -0,0 +1,257 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Threading; +using Microsoft.Win32.SafeHandles; + +namespace System.Diagnostics +{ + public partial class Process + { + /// + /// Reads from both standard output and standard error pipes using Windows overlapped IO + /// with wait handles for single-threaded synchronous multiplexing. + /// + private static unsafe void ReadPipes( + SafeFileHandle outputHandle, + SafeFileHandle errorHandle, + int timeoutMs, + ref byte[] outputBuffer, + ref int outputBytesRead, + ref byte[] errorBuffer, + ref int errorBytesRead) + { + PinnedGCHandle outputPin = default, errorPin = default; + NativeOverlapped* outputOverlapped = null, errorOverlapped = null; + EventWaitHandle? outputEvent = null, errorEvent = null; + + try + { + outputPin = new PinnedGCHandle(outputBuffer); + errorPin = new PinnedGCHandle(errorBuffer); + + outputEvent = new EventWaitHandle(initialState: false, EventResetMode.ManualReset); + errorEvent = new EventWaitHandle(initialState: false, EventResetMode.ManualReset); + + outputOverlapped = AllocateOverlapped(outputEvent); + errorOverlapped = AllocateOverlapped(errorEvent); + + // Error output gets index 0 so WaitAny services it first when both are signaled. + WaitHandle[] waitHandles = [errorEvent, outputEvent]; + + // Issue initial reads. + bool outputDone = !QueueRead(outputHandle, outputPin.GetAddressOfArrayData(), outputBuffer.Length, outputOverlapped, outputEvent); + bool errorDone = !QueueRead(errorHandle, errorPin.GetAddressOfArrayData(), errorBuffer.Length, errorOverlapped, errorEvent); + + long deadline = timeoutMs >= 0 + ? Environment.TickCount64 + timeoutMs + : long.MaxValue; + + while (!outputDone || !errorDone) + { + + int waitResult = TryGetRemainingTimeout(deadline, timeoutMs, out int remainingMilliseconds) + ? WaitHandle.WaitAny(waitHandles, remainingMilliseconds) + : WaitHandle.WaitTimeout; + + if (waitResult == WaitHandle.WaitTimeout) + { + CancelPendingIOIfNeeded(outputHandle, outputDone, outputOverlapped); + CancelPendingIOIfNeeded(errorHandle, errorDone, errorOverlapped); + + throw new TimeoutException(); + } + + bool isError = waitResult == 0; + NativeOverlapped* currentOverlapped = isError ? errorOverlapped : outputOverlapped; + SafeFileHandle currentHandle = isError ? errorHandle : outputHandle; + ref int totalBytesRead = ref (isError ? ref errorBytesRead : ref outputBytesRead); + ref byte[] currentBuffer = ref (isError ? ref errorBuffer : ref outputBuffer); + EventWaitHandle currentEvent = isError ? errorEvent : outputEvent; + + int bytesRead = GetOverlappedResultForPipe(currentHandle, currentOverlapped); + + if (bytesRead > 0) + { + totalBytesRead += bytesRead; + + if (totalBytesRead == currentBuffer.Length) + { + ref PinnedGCHandle currentPin = ref (isError ? ref errorPin : ref outputPin); + + RentLargerBuffer(ref currentBuffer, totalBytesRead); + + currentPin.Target = currentBuffer; + } + + // Reset the event and overlapped for next read. + ResetOverlapped(currentEvent, currentOverlapped); + + byte* pinPointer = isError ? errorPin.GetAddressOfArrayData() : outputPin.GetAddressOfArrayData(); + if (!QueueRead(currentHandle, pinPointer + totalBytesRead, + currentBuffer.Length - totalBytesRead, currentOverlapped, currentEvent)) + { + if (isError) + { + errorDone = true; + } + else + { + outputDone = true; + } + + // Ensure WaitAny won't trigger on this stale handle. + currentEvent.Reset(); + } + } + else + { + // EOF: pipe write end was closed. + if (isError) + { + errorDone = true; + } + else + { + outputDone = true; + } + + // Reset the event so WaitAny won't trigger on this stale handle. + currentEvent.Reset(); + } + } + } + finally + { + if (outputOverlapped is not null) + { + NativeMemory.Free(outputOverlapped); + } + + if (errorOverlapped is not null) + { + NativeMemory.Free(errorOverlapped); + } + + outputEvent?.Dispose(); + errorEvent?.Dispose(); + outputPin.Dispose(); + errorPin.Dispose(); + } + } + + private static unsafe NativeOverlapped* AllocateOverlapped(EventWaitHandle waitHandle) + { + NativeOverlapped* overlapped = (NativeOverlapped*)NativeMemory.AllocZeroed((nuint)sizeof(NativeOverlapped)); + overlapped->EventHandle = SetLowOrderBit(waitHandle); + + return overlapped; + } + + private static unsafe void ResetOverlapped(EventWaitHandle waitHandle, NativeOverlapped* overlapped) + { + waitHandle.Reset(); + + overlapped->InternalHigh = IntPtr.Zero; + overlapped->InternalLow = IntPtr.Zero; + overlapped->OffsetHigh = 0; + overlapped->OffsetLow = 0; + overlapped->EventHandle = SetLowOrderBit(waitHandle); + } + + private static unsafe int GetOverlappedResultForPipe(SafeFileHandle handle, NativeOverlapped* overlapped) + { + int bytesRead = 0; + if (!Interop.Kernel32.GetOverlappedResult(handle, overlapped, ref bytesRead, bWait: false)) + { + int errorCode = Marshal.GetLastPInvokeError(); + switch (errorCode) + { + case Interop.Errors.ERROR_HANDLE_EOF: // logically success with 0 bytes read (read at end of file) + case Interop.Errors.ERROR_BROKEN_PIPE: // For pipes, ERROR_BROKEN_PIPE is the normal end of the pipe. + case Interop.Errors.ERROR_PIPE_NOT_CONNECTED: // Named pipe server has disconnected, return 0 to match NamedPipeClientStream behaviour + return 0; // EOF! + default: + throw new Win32Exception(errorCode); + } + } + + return bytesRead; + } + + /// + /// Cancels a pending overlapped I/O and waits for completion before returning. + /// + private static unsafe void CancelPendingIOIfNeeded(SafeFileHandle handle, bool done, NativeOverlapped* overlapped) + { + if (done) + { + return; + } + + // CancelIoEx marks matching outstanding I/O requests for cancellation. + // It does not wait for all canceled operations to complete. + // When CancelIoEx returns true, it means that the cancel request was successfully queued. + if (!Interop.Kernel32.CancelIoEx(handle, overlapped)) + { + // Failure has two common meanings: + // ERROR_NOT_FOUND (extremely common). It means: + // - The I/O already completed. + // - Or it never existed. + // - Or it completed between your decision and the call. + // Other errors indicate real failures (invalid handle, driver limitation, etc.). + int errorCode = Marshal.GetLastPInvokeError(); + Debug.Assert(errorCode == Interop.Errors.ERROR_NOT_FOUND, $"CancelIoEx failed with {errorCode}."); + } + + // We must observe completion before freeing the OVERLAPPED in all the above scenarios. + // Use bWait: true to ensure the I/O operation completes before we free the OVERLAPPED structure. + // Per MSDN: "Do not reuse or free the OVERLAPPED structure until GetOverlappedResult returns." + int bytesRead = 0; + if (!Interop.Kernel32.GetOverlappedResult(handle, overlapped, ref bytesRead, bWait: true)) + { + int errorCode = Marshal.GetLastPInvokeError(); + Debug.Assert(errorCode is Interop.Errors.ERROR_OPERATION_ABORTED or Interop.Errors.ERROR_BROKEN_PIPE, $"GetOverlappedResult failed with {errorCode}."); + } + } + + /// + /// Returns the event handle with the low-order bit set. + /// Per https://learn.microsoft.com/windows/win32/api/ioapiset/nf-ioapiset-getqueuedcompletionstatus, + /// setting the low-order bit of hEvent in the OVERLAPPED structure prevents the I/O completion + /// from being queued to a completion port bound to the same file object. The kernel masks off + /// the bit when signaling, so the event still works normally. + /// + private static nint SetLowOrderBit(EventWaitHandle waitHandle) + => waitHandle.SafeWaitHandle.DangerousGetHandle() | 1; + + private static unsafe bool QueueRead( + SafeFileHandle handle, + byte* buffer, + int bufferLength, + NativeOverlapped* overlapped, + EventWaitHandle waitHandle) + { + if (Interop.Kernel32.ReadFile(handle, buffer, bufferLength, IntPtr.Zero, overlapped) != 0) + { + waitHandle.Set(); + return true; + } + + int error = Marshal.GetLastPInvokeError(); + if (error == Interop.Errors.ERROR_IO_PENDING) + { + return true; + } + + if (error == Interop.Errors.ERROR_BROKEN_PIPE || error == Interop.Errors.ERROR_HANDLE_EOF) + { + return false; + } + + throw new Win32Exception(error); + } + } +} diff --git a/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Multiplexing.cs b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Multiplexing.cs new file mode 100644 index 00000000000000..8da1afc4b1a6ad --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/src/System/Diagnostics/Process.Multiplexing.cs @@ -0,0 +1,254 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using Microsoft.Win32.SafeHandles; + +namespace System.Diagnostics +{ + public partial class Process + { + /// Initial buffer size for reading process output. + private const int InitialReadAllBufferSize = 4096; + + /// + /// Reads all standard output and standard error of the process as text. + /// + /// + /// The maximum amount of time to wait for the streams to be fully read. + /// When , waits indefinitely. + /// + /// + /// A tuple containing the standard output and standard error text. + /// + /// + /// Standard output or standard error has not been redirected. + /// -or- + /// A redirected stream has already been used for synchronous or asynchronous reading. + /// + /// + /// The operation did not complete within the specified . + /// + /// + /// The process has been disposed. + /// + public (string StandardOutput, string StandardError) ReadAllText(TimeSpan? timeout = default) + { + ValidateReadAllState(); + + byte[] outputBuffer = ArrayPool.Shared.Rent(InitialReadAllBufferSize); + byte[] errorBuffer = ArrayPool.Shared.Rent(InitialReadAllBufferSize); + int outputBytesRead = 0; + int errorBytesRead = 0; + + try + { + ReadPipesToBuffers(timeout, ref outputBuffer, ref outputBytesRead, ref errorBuffer, ref errorBytesRead); + + Encoding outputEncoding = _startInfo?.StandardOutputEncoding ?? GetStandardOutputEncoding(); + Encoding errorEncoding = _startInfo?.StandardErrorEncoding ?? GetStandardOutputEncoding(); + + string standardOutput = outputBytesRead > 0 + ? outputEncoding.GetString(outputBuffer, 0, outputBytesRead) + : string.Empty; + + string standardError = errorBytesRead > 0 + ? errorEncoding.GetString(errorBuffer, 0, errorBytesRead) + : string.Empty; + + return (standardOutput, standardError); + } + finally + { + ArrayPool.Shared.Return(outputBuffer); + ArrayPool.Shared.Return(errorBuffer); + } + } + + /// + /// Reads all standard output and standard error of the process as byte arrays. + /// + /// + /// The maximum amount of time to wait for the streams to be fully read. + /// When , waits indefinitely. + /// + /// + /// A tuple containing the standard output and standard error bytes. + /// + /// + /// Standard output or standard error has not been redirected. + /// -or- + /// A redirected stream has already been used for synchronous or asynchronous reading. + /// + /// + /// The operation did not complete within the specified . + /// + /// + /// The process has been disposed. + /// + public (byte[] StandardOutput, byte[] StandardError) ReadAllBytes(TimeSpan? timeout = default) + { + ValidateReadAllState(); + + byte[] outputBuffer = ArrayPool.Shared.Rent(InitialReadAllBufferSize); + byte[] errorBuffer = ArrayPool.Shared.Rent(InitialReadAllBufferSize); + int outputBytesRead = 0; + int errorBytesRead = 0; + + try + { + ReadPipesToBuffers(timeout, ref outputBuffer, ref outputBytesRead, ref errorBuffer, ref errorBytesRead); + + byte[] outputResult = outputBytesRead > 0 + ? outputBuffer.AsSpan(0, outputBytesRead).ToArray() + : Array.Empty(); + + byte[] errorResult = errorBytesRead > 0 + ? errorBuffer.AsSpan(0, errorBytesRead).ToArray() + : Array.Empty(); + + return (outputResult, errorResult); + } + finally + { + ArrayPool.Shared.Return(outputBuffer); + ArrayPool.Shared.Return(errorBuffer); + } + } + + /// + /// Validates that the process is not disposed, both stdout and stderr are redirected, + /// and neither stream has been used (mode must be Undefined). Sets both streams to sync mode. + /// + private void ValidateReadAllState() + { + CheckDisposed(); + + if (_standardOutput is null) + { + throw new InvalidOperationException(SR.CantGetStandardOut); + } + else if (_standardError is null) + { + throw new InvalidOperationException(SR.CantGetStandardError); + } + else if (_outputStreamReadMode != StreamReadMode.Undefined) + { + throw new InvalidOperationException(SR.CantMixSyncAsyncOperation); + } + else if (_errorStreamReadMode != StreamReadMode.Undefined) + { + throw new InvalidOperationException(SR.CantMixSyncAsyncOperation); + } + + _outputStreamReadMode = StreamReadMode.SyncMode; + _errorStreamReadMode = StreamReadMode.SyncMode; + } + + /// + /// Obtains handles and reads both stdout and stderr pipes into the provided buffers. + /// The caller is responsible for calling before renting buffers, + /// and for renting and returning the buffers. + /// + private void ReadPipesToBuffers( + TimeSpan? timeout, + ref byte[] outputBuffer, + ref int outputBytesRead, + ref byte[] errorBuffer, + ref int errorBytesRead) + { + int timeoutMs = timeout.HasValue + ? ToTimeoutMilliseconds(timeout.Value) + : Timeout.Infinite; + + SafeFileHandle outputHandle = GetSafeFileHandleFromStreamReader(_standardOutput!, out SafeHandle outputOwner); + SafeFileHandle errorHandle = GetSafeFileHandleFromStreamReader(_standardError!, out SafeHandle errorOwner); + + bool outputRefAdded = false; + bool errorRefAdded = false; + + try + { + outputOwner.DangerousAddRef(ref outputRefAdded); + errorOwner.DangerousAddRef(ref errorRefAdded); + + ReadPipes(outputHandle, errorHandle, timeoutMs, + ref outputBuffer, ref outputBytesRead, + ref errorBuffer, ref errorBytesRead); + } + finally + { + if (outputRefAdded) + { + outputOwner.DangerousRelease(); + } + + if (errorRefAdded) + { + errorOwner.DangerousRelease(); + } + } + } + + /// + /// Obtains the from the underlying stream of a . + /// On Unix, the stream is an and the handle is obtained via the pipe handle. + /// On Windows, the stream is a opened for async IO. + /// + private static SafeFileHandle GetSafeFileHandleFromStreamReader(StreamReader reader, out SafeHandle owner) + { + Stream baseStream = reader.BaseStream; + + if (baseStream is FileStream fileStream) + { + owner = fileStream.SafeFileHandle; + return fileStream.SafeFileHandle; + } + + if (baseStream is System.IO.Pipes.AnonymousPipeClientStream pipeStream) + { + owner = pipeStream.SafePipeHandle; + return new SafeFileHandle(pipeStream.SafePipeHandle.DangerousGetHandle(), ownsHandle: false); + } + + throw new UnreachableException(); + } + + /// + /// Rents a larger buffer from the array pool and copies the existing data to it. + /// + private static void RentLargerBuffer(ref byte[] buffer, int bytesRead) + { + int newSize = (int)Math.Min((long)buffer.Length * 2, Array.MaxLength); + newSize = Math.Max(buffer.Length + 1, newSize); + byte[] newBuffer = ArrayPool.Shared.Rent(newSize); + Buffer.BlockCopy(buffer, 0, newBuffer, 0, bytesRead); + byte[] oldBuffer = buffer; + buffer = newBuffer; + ArrayPool.Shared.Return(oldBuffer); + } + + private static bool TryGetRemainingTimeout(long deadline, int originalTimeout, out int remainingTimeoutMs) + { + if (originalTimeout < 0) + { + remainingTimeoutMs = Timeout.Infinite; + return true; + } + + long remaining = deadline - Environment.TickCount64; + if (remaining <= 0) + { + remainingTimeoutMs = 0; + return false; + } + + remainingTimeoutMs = (int)Math.Min(remaining, int.MaxValue); + return true; + } + } +} diff --git a/src/libraries/System.Diagnostics.Process/tests/ProcessMultiplexingTests.cs b/src/libraries/System.Diagnostics.Process/tests/ProcessMultiplexingTests.cs new file mode 100644 index 00000000000000..906cbbe4c47202 --- /dev/null +++ b/src/libraries/System.Diagnostics.Process/tests/ProcessMultiplexingTests.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.IO; +using System.Text; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.Win32.SafeHandles; +using Xunit; + +namespace System.Diagnostics.Tests +{ + public class ProcessMultiplexingTests : ProcessTestBase + { + private const string DontPrintAnything = "DO_NOT_PRINT"; + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(true)] + [InlineData(false)] + public void ReadAll_ThrowsAfterDispose(bool bytes) + { + Process process = CreateProcess(RemotelyInvokable.Dummy); + process.Start(); + Assert.True(process.WaitForExit(WaitInMS)); + + process.Dispose(); + + if (bytes) + { + Assert.Throws(() => process.ReadAllBytes()); + } + else + { + Assert.Throws(() => process.ReadAllText()); + } + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(true)] + [InlineData(false)] + public void ReadAll_ThrowsWhenNoStreamsRedirected(bool bytes) + { + Process process = CreateProcess(RemotelyInvokable.Dummy); + process.Start(); + + if (bytes) + { + Assert.Throws(() => process.ReadAllBytes()); + } + else + { + Assert.Throws(() => process.ReadAllText()); + } + + Assert.True(process.WaitForExit(WaitInMS)); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void ReadAll_ThrowsWhenOnlyOutputOrErrorIsRedirected(bool bytes, bool standardOutput) + { + Process process = CreateProcess(RemotelyInvokable.Dummy); + process.StartInfo.RedirectStandardOutput = standardOutput; + process.StartInfo.RedirectStandardError = !standardOutput; + process.Start(); + + if (bytes) + { + Assert.Throws(() => process.ReadAllBytes()); + } + else + { + Assert.Throws(() => process.ReadAllText()); + } + + Assert.True(process.WaitForExit(WaitInMS)); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void ReadAll_ThrowsWhenOutputOrErrorIsInSyncMode(bool bytes, bool standardOutput) + { + Process process = CreateProcess(RemotelyInvokable.Dummy); + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.Start(); + + // Access the StreamReader property to set the stream to sync mode + _ = standardOutput ? process.StandardOutput : process.StandardError; + + if (bytes) + { + Assert.Throws(() => process.ReadAllBytes()); + } + else + { + Assert.Throws(() => process.ReadAllText()); + } + + Assert.True(process.WaitForExit(WaitInMS)); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public void ReadAll_ThrowsWhenOutputOrErrorIsInAsyncMode(bool bytes, bool standardOutput) + { + Process process = CreateProcess(RemotelyInvokable.StreamBody); + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.Start(); + + if (standardOutput) + { + process.BeginOutputReadLine(); + } + else + { + process.BeginErrorReadLine(); + } + + if (bytes) + { + Assert.Throws(() => process.ReadAllBytes()); + } + else + { + Assert.Throws(() => process.ReadAllText()); + } + + if (standardOutput) + { + process.CancelOutputRead(); + } + else + { + process.CancelErrorRead(); + } + + Assert.True(process.WaitForExit(WaitInMS)); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData(true)] + [InlineData(false)] + public void ReadAll_ThrowsTimeoutExceptionOnTimeout(bool bytes) + { + Process process = CreateProcess(RemotelyInvokable.ReadLine); + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.RedirectStandardInput = true; + process.Start(); + + try + { + if (bytes) + { + Assert.Throws(() => process.ReadAllBytes(TimeSpan.FromMilliseconds(100))); + } + else + { + Assert.Throws(() => process.ReadAllText(TimeSpan.FromMilliseconds(100))); + } + } + finally + { + process.Kill(); + } + + Assert.True(process.WaitForExit(WaitInMS)); + } + + [ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + [InlineData("hello", "world", true)] + [InlineData("hello", "world", false)] + [InlineData("just output", "", true)] + [InlineData("just output", "", false)] + [InlineData("", "just error", true)] + [InlineData("", "just error", false)] + [InlineData("", "", true)] + [InlineData("", "", false)] + public void ReadAll_ReadsBothOutputAndError(string standardOutput, string standardError, bool bytes) + { + using Process process = StartPrintingProcess( + string.IsNullOrEmpty(standardOutput) ? DontPrintAnything : standardOutput, + string.IsNullOrEmpty(standardError) ? DontPrintAnything : standardError); + + if (bytes) + { + (byte[] capturedOutput, byte[] capturedError) = process.ReadAllBytes(); + + Assert.Equal(Encoding.Default.GetBytes(standardOutput), capturedOutput); + Assert.Equal(Encoding.Default.GetBytes(standardError), capturedError); + } + else + { + (string capturedOutput, string capturedError) = process.ReadAllText(); + + Assert.Equal(standardOutput, capturedOutput); + Assert.Equal(standardError, capturedError); + } + + Assert.True(process.WaitForExit(WaitInMS)); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void ReadAllText_ReadsInterleavedOutput() + { + const int iterations = 100; + using Process process = CreateProcess(() => + { + for (int i = 0; i < iterations; i++) + { + Console.Out.Write($"out{i} "); + Console.Out.Flush(); + Console.Error.Write($"err{i} "); + Console.Error.Flush(); + } + + return RemoteExecutor.SuccessExitCode; + }); + + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.Start(); + + (string standardOutput, string standardError) = process.ReadAllText(); + + StringBuilder expectedOutput = new(); + StringBuilder expectedError = new(); + for (int i = 0; i < iterations; i++) + { + expectedOutput.Append($"out{i} "); + expectedError.Append($"err{i} "); + } + + Assert.Equal(expectedOutput.ToString(), standardOutput); + Assert.Equal(expectedError.ToString(), standardError); + + Assert.True(process.WaitForExit(WaitInMS)); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void ReadAllBytes_ReadsBinaryDataWithNullBytes() + { + string testFilePath = GetTestFilePath(); + byte[] binaryData = new byte[1024]; + Random.Shared.NextBytes(binaryData); + // Ensure there are null bytes throughout the data. + for (int i = 0; i < binaryData.Length; i += 10) + { + binaryData[i] = 0; + } + + File.WriteAllBytes(testFilePath, binaryData); + + using Process process = CreateProcess(static path => + { + using FileStream source = File.OpenRead(path); + source.CopyTo(Console.OpenStandardOutput()); + + return RemoteExecutor.SuccessExitCode; + }, testFilePath); + + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.Start(); + + (byte[] standardOutput, byte[] standardError) = process.ReadAllBytes(); + + Assert.Equal(binaryData, standardOutput); + Assert.Empty(standardError); + Assert.True(process.WaitForExit(WaitInMS)); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void ReadAllText_ReadsLargeOutput() + { + string testFilePath = GetTestFilePath(); + string largeText = new string('A', 100_000); + File.WriteAllText(testFilePath, largeText); + + using Process process = CreateProcess(static path => + { + using FileStream source = File.OpenRead(path); + source.CopyTo(Console.OpenStandardOutput()); + + return RemoteExecutor.SuccessExitCode; + }, testFilePath); + + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.Start(); + + (string standardOutput, string standardError) = process.ReadAllText(); + + Assert.Equal(largeText, standardOutput); + Assert.Equal(string.Empty, standardError); + Assert.True(process.WaitForExit(WaitInMS)); + } + + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public void ReadAllBytes_ReadsLargeOutput() + { + string testFilePath = GetTestFilePath(); + byte[] largeByteArray = new byte[100_000]; + Random.Shared.NextBytes(largeByteArray); + File.WriteAllBytes(testFilePath, largeByteArray); + + using Process process = CreateProcess(static path => + { + using FileStream source = File.OpenRead(path); + source.CopyTo(Console.OpenStandardOutput()); + + return RemoteExecutor.SuccessExitCode; + }, testFilePath); + + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.Start(); + + (byte[] standardOutput, byte[] standardError) = process.ReadAllBytes(); + + Assert.Equal(largeByteArray, standardOutput); + Assert.Empty(standardError); + Assert.True(process.WaitForExit(WaitInMS)); + } + + private Process StartPrintingProcess(string stdOutText, string stdErrText) + { + Process process = CreateProcess((stdOut, stdErr) => + { + if (stdOut != DontPrintAnything) + { + Console.Out.Write(stdOut); + } + + if (stdErr != DontPrintAnything) + { + Console.Error.Write(stdErr); + } + + return RemoteExecutor.SuccessExitCode; + }, stdOutText, stdErrText); + + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.Start(); + + return process; + } + } +} 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 5c345d299a1ac8..dd73a7baa7f533 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 @@ -31,6 +31,7 @@ +