From e960631e9216ed7efd5acc4daba044a6a0cf89d6 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Wed, 19 Nov 2025 13:45:17 -0800 Subject: [PATCH 1/7] Close streams when the ssh process exits on Windows to avoid hang --- .../engine/Utils.cs | 11 ++++ .../remoting/common/RunspaceConnectionInfo.cs | 63 ++++++++++++------- .../fanin/OutOfProcTransportManager.cs | 17 ++--- .../Remoting/SSHRemotingCmdlets.Tests.ps1 | 21 +++++++ 4 files changed, 84 insertions(+), 28 deletions(-) diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index 0de9fe0d5cc..591d70d9347 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -1613,6 +1613,17 @@ internal static bool GetEnvironmentVariableAsBool(string name, bool defaultValue return defaultValue; } + + internal static void SafeDispose(IDisposable disposable) + { + if (disposable is { }) + { + lock (disposable) + { + disposable.Dispose(); + } + } + } } } diff --git a/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs b/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs index b6913e0cc1c..6488270204d 100644 --- a/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs +++ b/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs @@ -2211,11 +2211,11 @@ internal int StartSSHProcess( CommandTypes.Application, SearchResolutionOptions.None, CommandOrigin.Internal, - context) as ApplicationInfo; + context); - if (cmdInfo != null) + if (cmdInfo is ApplicationInfo appInfo) { - filePath = cmdInfo.Path; + filePath = appInfo.Path; } } else @@ -2273,13 +2273,13 @@ internal int StartSSHProcess( // Subsystem powershell /usr/local/bin/pwsh -SSHServerMode -NoLogo -NoProfile // codeql[cs/microsoft/command-line-injection-shell-execution] - This is expected Poweshell behavior where user inputted paths are supported for the context of this method. The user assumes trust for the file path specified, so any file executed in the runspace would be in the user's local system/process or a system they have access to in which case restricted remoting security guidelines should be used. - System.Diagnostics.ProcessStartInfo startInfo = new System.Diagnostics.ProcessStartInfo(filePath); + ProcessStartInfo startInfo = new(filePath); // pass "-i identity_file" command line argument to ssh if KeyFilePath is set // if KeyFilePath is not set, then ssh will use IdentityFile / IdentityAgent from ssh_config if defined else none by default if (!string.IsNullOrEmpty(this.KeyFilePath)) { - if (!System.IO.File.Exists(this.KeyFilePath)) + if (!File.Exists(this.KeyFilePath)) { throw new FileNotFoundException( StringUtil.Format(RemotingErrorIdStrings.KeyFileNotFound, this.KeyFilePath)); @@ -2326,7 +2326,7 @@ internal int StartSSHProcess( // note that ssh expects IPv6 addresses to not be enclosed in square brackets so trim them if present startInfo.ArgumentList.Add(string.Create(CultureInfo.InvariantCulture, $@"-s {this.ComputerName.TrimStart('[').TrimEnd(']')} {this.Subsystem}")); - startInfo.WorkingDirectory = System.IO.Path.GetDirectoryName(filePath); + startInfo.WorkingDirectory = Path.GetDirectoryName(filePath); startInfo.CreateNoWindow = true; startInfo.UseShellExecute = false; @@ -2580,7 +2580,7 @@ private static unsafe void AllocNullTerminatedArray(string[] arr, ref byte** arr // Allocate the unmanaged array to hold each string pointer. // It needs to have an extra element to null terminate the array. arrPtr = (byte**)Marshal.AllocHGlobal(sizeof(IntPtr) * arrLength); - System.Diagnostics.Debug.Assert(arrPtr != null, "Invalid array ptr"); + Debug.Assert(arrPtr != null, "Invalid array ptr"); // Zero the memory so that if any of the individual string allocations fails, // we can loop through the array to free any that succeeded. @@ -2597,7 +2597,7 @@ private static unsafe void AllocNullTerminatedArray(string[] arr, ref byte** arr byte[] byteArr = System.Text.Encoding.UTF8.GetBytes(arr[i]); arrPtr[i] = (byte*)Marshal.AllocHGlobal(byteArr.Length + 1); // +1 for null termination - System.Diagnostics.Debug.Assert(arrPtr[i] != null, "Invalid array ptr"); + Debug.Assert(arrPtr[i] != null, "Invalid array ptr"); Marshal.Copy(byteArr, 0, (IntPtr)arrPtr[i], byteArr.Length); // copy over the data from the managed byte array arrPtr[i][byteArr.Length] = (byte)'\0'; // null terminate @@ -2641,13 +2641,13 @@ internal static extern unsafe int ForkAndExecProcess( /// P-Invoking native APIs. /// private static int StartSSHProcessImpl( - System.Diagnostics.ProcessStartInfo startInfo, - out StreamWriter stdInWriterVar, - out StreamReader stdOutReaderVar, - out StreamReader stdErrReaderVar) + ProcessStartInfo startInfo, + out StreamWriter stdInWriter, + out StreamReader stdOutReader, + out StreamReader stdErrReader) { Exception ex = null; - System.Diagnostics.Process sshProcess = null; + Process sshProcess = null; // // These std pipe handles are bound to managed Reader/Writer objects and returned to the transport // manager object, which uses them for PSRP communication. The lifetime of these handles are then @@ -2668,7 +2668,7 @@ private static int StartSSHProcessImpl( catch (InvalidOperationException e) { ex = e; } catch (ArgumentException e) { ex = e; } catch (FileNotFoundException e) { ex = e; } - catch (System.ComponentModel.Win32Exception e) { ex = e; } + catch (Win32Exception e) { ex = e; } if ((ex != null) || (sshProcess == null) || @@ -2680,9 +2680,9 @@ private static int StartSSHProcessImpl( } // Create the std in writer/readers needed for communication with ssh.exe. - stdInWriterVar = null; - stdOutReaderVar = null; - stdErrReaderVar = null; + StreamWriter stdInWriterVar = null; + StreamReader stdOutReaderVar = null; + StreamReader stdErrReaderVar = null; try { stdInWriterVar = new StreamWriter(new NamedPipeServerStream(PipeDirection.Out, true, true, stdInPipeServer)); @@ -2693,19 +2693,40 @@ private static int StartSSHProcessImpl( { if (stdInWriterVar != null) { stdInWriterVar.Dispose(); } else { stdInPipeServer.Dispose(); } - if (stdOutReaderVar != null) { stdInWriterVar.Dispose(); } else { stdOutPipeServer.Dispose(); } + if (stdOutReaderVar != null) { stdOutReaderVar.Dispose(); } else { stdOutPipeServer.Dispose(); } - if (stdErrReaderVar != null) { stdInWriterVar.Dispose(); } else { stdErrPipeServer.Dispose(); } + if (stdErrReaderVar != null) { stdErrReaderVar.Dispose(); } else { stdErrPipeServer.Dispose(); } throw; } + // On Windows, the ssh process may exit upon error but leave the pipes open, which could result in a hang. + // So, we close the pipe handles when the ssh process exits to avoid this situation. + sshProcess.EnableRaisingEvents = true; + sshProcess.Exited += (sender, args) => + { + try + { + Utils.SafeDispose(stdInWriterVar); + Utils.SafeDispose(stdOutReaderVar); + Utils.SafeDispose(stdErrReaderVar); + } + catch + { + // Ignore all exceptions in the event handler. + } + }; + + stdInWriter = stdInWriterVar; + stdOutReader = stdOutReaderVar; + stdErrReader = stdErrReaderVar; + return sshProcess.Id; } private static void KillSSHProcessImpl(int pid) { - using (var sshProcess = System.Diagnostics.Process.GetProcessById(pid)) + using (var sshProcess = Process.GetProcessById(pid)) { if ((sshProcess != null) && (sshProcess.Handle != IntPtr.Zero) && !sshProcess.HasExited) { @@ -2736,7 +2757,7 @@ private static Process CreateProcessWithRedirectedStd( SafeFileHandle stdInPipeClient = null; SafeFileHandle stdOutPipeClient = null; SafeFileHandle stdErrPipeClient = null; - string randomName = System.IO.Path.GetFileNameWithoutExtension(System.IO.Path.GetRandomFileName()); + string randomName = Path.GetFileNameWithoutExtension(Path.GetRandomFileName()); try { diff --git a/src/System.Management.Automation/engine/remoting/fanin/OutOfProcTransportManager.cs b/src/System.Management.Automation/engine/remoting/fanin/OutOfProcTransportManager.cs index d9532c8691a..1a049d2469a 100644 --- a/src/System.Management.Automation/engine/remoting/fanin/OutOfProcTransportManager.cs +++ b/src/System.Management.Automation/engine/remoting/fanin/OutOfProcTransportManager.cs @@ -1733,7 +1733,7 @@ public override void CreateAsync() bool sshTerminated = false; try { - using (var sshProcess = System.Diagnostics.Process.GetProcessById(_sshProcessId)) + using (var sshProcess = Process.GetProcessById(_sshProcessId)) { sshTerminated = sshProcess == null || sshProcess.Handle == IntPtr.Zero || sshProcess.HasExited; } @@ -1781,14 +1781,17 @@ private void CloseConnection() var connectionTimer = Interlocked.Exchange(ref _connectionTimer, null); connectionTimer?.Dispose(); + // On Windows, the stdIn, stdOut, stdErr writer/readers are also disposed in the 'Exited' event of the ssh process. + // In that case, the 'Dispose' method could be called in parallel on them, which might result in a race condition. + // So, we handle their disposal here in a thread-safe manner. var stdInWriter = Interlocked.Exchange(ref _stdInWriter, null); - stdInWriter?.Dispose(); + Utils.SafeDispose(stdInWriter); var stdOutReader = Interlocked.Exchange(ref _stdOutReader, null); - stdOutReader?.Dispose(); + Utils.SafeDispose(stdOutReader); var stdErrReader = Interlocked.Exchange(ref _stdErrReader, null); - stdErrReader?.Dispose(); + Utils.SafeDispose(stdErrReader); // The CloseConnection() method can be called multiple times from multiple places. // Set the _sshProcessId to zero here so that we go through the work of finding @@ -1847,7 +1850,7 @@ private void ProcessErrorThread(object state) // Messages in error stream from ssh are unreliable, and may just be warnings or // banner text. // So just report the messages but don't act on them. - System.Console.WriteLine(error); + Console.WriteLine(error); } catch (IOException) { } @@ -1907,10 +1910,10 @@ private void ProcessReaderThread(object state) break; } - if (data.StartsWith(System.Management.Automation.Remoting.Server.FormattedErrorTextWriter.ErrorPrefix, StringComparison.OrdinalIgnoreCase)) + if (data.StartsWith(OutOfProcessTextWriter.ErrorPrefix, StringComparison.OrdinalIgnoreCase)) { // Error message from the server. - string errorData = data.Substring(System.Management.Automation.Remoting.Server.FormattedErrorTextWriter.ErrorPrefix.Length); + string errorData = data.Substring(OutOfProcessTextWriter.ErrorPrefix.Length); HandleErrorDataReceived(errorData); } else diff --git a/test/powershell/engine/Remoting/SSHRemotingCmdlets.Tests.ps1 b/test/powershell/engine/Remoting/SSHRemotingCmdlets.Tests.ps1 index 1568d284d0f..9994d761e8a 100644 --- a/test/powershell/engine/Remoting/SSHRemotingCmdlets.Tests.ps1 +++ b/test/powershell/engine/Remoting/SSHRemotingCmdlets.Tests.ps1 @@ -70,3 +70,24 @@ Describe "SSHConnection parameter hashtable type conversions" -Tags 'Feature', ' $err.FullyQualifiedErrorId | Should -Match 'PSSessionOpenFailed' } } + +Describe "No hangs when host doesn't exist" -Tag "CI" { + $testCases = @( + @{ + Name = 'Verifies no hang for New-PSSession with non-existing host name' + ScriptBlock = { New-PSSession -HostName "test-notexist" -UserName "test" -ErrorAction Stop } + FullyQualifiedErrorId = 'PSSessionOpenFailed' + }, + @{ + Name = 'Verifies no hang for Invoke-Command with non-existing host name' + ScriptBlock = { Invoke-Command -HostName "test-notexist" -UserName "test" -ScriptBlock { 1 } -ErrorAction Stop } + FullyQualifiedErrorId = 'PSSessionStateBroken' + } + ) + + It "" -TestCases $testCases { + param ($ScriptBlock, $FullyQualifiedErrorId) + + $ScriptBlock | Should -Throw -ErrorId $FullyQualifiedErrorId -ExceptionType 'System.Management.Automation.Remoting.PSRemotingTransportException' + } +} From cdafc256c857aa72346d6319370d033d4a51f8c2 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Wed, 19 Nov 2025 16:09:31 -0800 Subject: [PATCH 2/7] Address a copilot comment --- .../engine/remoting/common/RunspaceConnectionInfo.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs b/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs index 6488270204d..77ea2f628a9 100644 --- a/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs +++ b/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs @@ -2711,9 +2711,9 @@ private static int StartSSHProcessImpl( Utils.SafeDispose(stdOutReaderVar); Utils.SafeDispose(stdErrReaderVar); } - catch + catch (Exception) { - // Ignore all exceptions in the event handler. + // Ignore all non-critical exceptions in the event handler. } }; From 5dcebf3e8453bb7cdbc0174eae7af0e17c9a6617 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Wed, 19 Nov 2025 21:34:40 -0800 Subject: [PATCH 3/7] Remove 'SafeDispose' --- .../engine/remoting/common/RunspaceConnectionInfo.cs | 6 +++--- .../engine/remoting/fanin/OutOfProcTransportManager.cs | 9 +++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs b/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs index 77ea2f628a9..fd2b63951c2 100644 --- a/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs +++ b/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs @@ -2707,9 +2707,9 @@ private static int StartSSHProcessImpl( { try { - Utils.SafeDispose(stdInWriterVar); - Utils.SafeDispose(stdOutReaderVar); - Utils.SafeDispose(stdErrReaderVar); + stdInWriterVar.Dispose(); + stdOutReaderVar.Dispose(); + stdErrReaderVar.Dispose(); } catch (Exception) { diff --git a/src/System.Management.Automation/engine/remoting/fanin/OutOfProcTransportManager.cs b/src/System.Management.Automation/engine/remoting/fanin/OutOfProcTransportManager.cs index 1a049d2469a..47ff6270dba 100644 --- a/src/System.Management.Automation/engine/remoting/fanin/OutOfProcTransportManager.cs +++ b/src/System.Management.Automation/engine/remoting/fanin/OutOfProcTransportManager.cs @@ -1781,17 +1781,14 @@ private void CloseConnection() var connectionTimer = Interlocked.Exchange(ref _connectionTimer, null); connectionTimer?.Dispose(); - // On Windows, the stdIn, stdOut, stdErr writer/readers are also disposed in the 'Exited' event of the ssh process. - // In that case, the 'Dispose' method could be called in parallel on them, which might result in a race condition. - // So, we handle their disposal here in a thread-safe manner. var stdInWriter = Interlocked.Exchange(ref _stdInWriter, null); - Utils.SafeDispose(stdInWriter); + stdInWriter?.Dispose(); var stdOutReader = Interlocked.Exchange(ref _stdOutReader, null); - Utils.SafeDispose(stdOutReader); + stdOutReader?.Dispose(); var stdErrReader = Interlocked.Exchange(ref _stdErrReader, null); - Utils.SafeDispose(stdErrReader); + stdErrReader?.Dispose(); // The CloseConnection() method can be called multiple times from multiple places. // Set the _sshProcessId to zero here so that we go through the work of finding From 53b45e105ac81e59fe59e25c4a1db8de8daa216c Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Wed, 19 Nov 2025 21:36:48 -0800 Subject: [PATCH 4/7] Actually remove the method --- src/System.Management.Automation/engine/Utils.cs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/System.Management.Automation/engine/Utils.cs b/src/System.Management.Automation/engine/Utils.cs index 591d70d9347..0de9fe0d5cc 100644 --- a/src/System.Management.Automation/engine/Utils.cs +++ b/src/System.Management.Automation/engine/Utils.cs @@ -1613,17 +1613,6 @@ internal static bool GetEnvironmentVariableAsBool(string name, bool defaultValue return defaultValue; } - - internal static void SafeDispose(IDisposable disposable) - { - if (disposable is { }) - { - lock (disposable) - { - disposable.Dispose(); - } - } - } } } From 8280e6c3f007578f820370b206bb49d249f77442 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Thu, 20 Nov 2025 10:52:19 -0800 Subject: [PATCH 5/7] Fix the root problem -- close the client pipe handle after its inherited by the child process --- .../remoting/common/RunspaceConnectionInfo.cs | 39 +++++-------------- 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs b/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs index fd2b63951c2..6e2cd43dd0b 100644 --- a/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs +++ b/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs @@ -2642,9 +2642,9 @@ internal static extern unsafe int ForkAndExecProcess( /// private static int StartSSHProcessImpl( ProcessStartInfo startInfo, - out StreamWriter stdInWriter, - out StreamReader stdOutReader, - out StreamReader stdErrReader) + out StreamWriter stdInWriterVar, + out StreamReader stdOutReaderVar, + out StreamReader stdErrReaderVar) { Exception ex = null; Process sshProcess = null; @@ -2680,9 +2680,9 @@ private static int StartSSHProcessImpl( } // Create the std in writer/readers needed for communication with ssh.exe. - StreamWriter stdInWriterVar = null; - StreamReader stdOutReaderVar = null; - StreamReader stdErrReaderVar = null; + stdInWriterVar = null; + stdOutReaderVar = null; + stdErrReaderVar = null; try { stdInWriterVar = new StreamWriter(new NamedPipeServerStream(PipeDirection.Out, true, true, stdInPipeServer)); @@ -2700,27 +2700,6 @@ private static int StartSSHProcessImpl( throw; } - // On Windows, the ssh process may exit upon error but leave the pipes open, which could result in a hang. - // So, we close the pipe handles when the ssh process exits to avoid this situation. - sshProcess.EnableRaisingEvents = true; - sshProcess.Exited += (sender, args) => - { - try - { - stdInWriterVar.Dispose(); - stdOutReaderVar.Dispose(); - stdErrReaderVar.Dispose(); - } - catch (Exception) - { - // Ignore all non-critical exceptions in the event handler. - } - }; - - stdInWriter = stdInWriterVar; - stdOutReader = stdOutReaderVar; - stdErrReader = stdErrReaderVar; - return sshProcess.Id; } @@ -2850,17 +2829,17 @@ private static Process CreateProcessWithRedirectedStd( catch (Exception) { stdInPipeServer?.Dispose(); - stdInPipeClient?.Dispose(); stdOutPipeServer?.Dispose(); - stdOutPipeClient?.Dispose(); stdErrPipeServer?.Dispose(); - stdErrPipeClient?.Dispose(); throw; } finally { lpProcessInformation.Dispose(); + stdInPipeClient?.Dispose(); + stdOutPipeClient?.Dispose(); + stdErrPipeClient?.Dispose(); } } From e9c438bba4e211cf12ef5fdabc2a7e6a66a68d23 Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Fri, 21 Nov 2025 14:35:46 -0800 Subject: [PATCH 6/7] Dipose the parent side pipe client handles by disposing 'lpStartupInfo' --- .../engine/remoting/common/RunspaceConnectionInfo.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs b/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs index 6e2cd43dd0b..242bde2ac53 100644 --- a/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs +++ b/src/System.Management.Automation/engine/remoting/common/RunspaceConnectionInfo.cs @@ -2836,10 +2836,8 @@ private static Process CreateProcessWithRedirectedStd( } finally { + lpStartupInfo.Dispose(); lpProcessInformation.Dispose(); - stdInPipeClient?.Dispose(); - stdOutPipeClient?.Dispose(); - stdErrPipeClient?.Dispose(); } } From e334ddb850b0719774ce1c94a2829f4a148e0f5a Mon Sep 17 00:00:00 2001 From: Dongbo Wang Date: Mon, 24 Nov 2025 11:37:05 -0800 Subject: [PATCH 7/7] Change to '-Tags' --- test/powershell/engine/Remoting/SSHRemotingCmdlets.Tests.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/powershell/engine/Remoting/SSHRemotingCmdlets.Tests.ps1 b/test/powershell/engine/Remoting/SSHRemotingCmdlets.Tests.ps1 index 9994d761e8a..c1090bdc186 100644 --- a/test/powershell/engine/Remoting/SSHRemotingCmdlets.Tests.ps1 +++ b/test/powershell/engine/Remoting/SSHRemotingCmdlets.Tests.ps1 @@ -71,7 +71,7 @@ Describe "SSHConnection parameter hashtable type conversions" -Tags 'Feature', ' } } -Describe "No hangs when host doesn't exist" -Tag "CI" { +Describe "No hangs when host doesn't exist" -Tags "CI" { $testCases = @( @{ Name = 'Verifies no hang for New-PSSession with non-existing host name'