diff --git a/src/Microsoft.PowerShell.Commands.Management/commands/management/Process.cs b/src/Microsoft.PowerShell.Commands.Management/commands/management/Process.cs index 30ef0e096ac..5c949445e3d 100644 --- a/src/Microsoft.PowerShell.Commands.Management/commands/management/Process.cs +++ b/src/Microsoft.PowerShell.Commands.Management/commands/management/Process.cs @@ -4,6 +4,7 @@ using System; using System.Text; +using System.Text.RegularExpressions; using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; @@ -1779,6 +1780,14 @@ public ProcessWindowStyle WindowStyle [Parameter] public SwitchParameter Wait { get; set; } + /// + /// If specified, wait for this number of milliseconds for the process to exit. + /// + [Parameter] + [ValidateNotNullOrEmpty] + [ValidateRange(ValidateRangeKind.Positive)] + public int ExitTimeout { get; set; } = Timeout.Infinite; + /// /// Default Environment /// @@ -2013,14 +2022,17 @@ protected override void BeginProcessing() } } - if (Wait.IsPresent) + if (Wait.IsPresent || ExitTimeout != Timeout.Infinite) { if (process != null) { if (!process.HasExited) { #if UNIX - process.WaitForExit(); + if (!process.WaitForExit(ExitTimeout)) + { + StopProcessOnTimeout(process); + } #else _waithandle = new ManualResetEvent(false); @@ -2028,15 +2040,22 @@ protected override void BeginProcessing() ProcessCollection jobObject = new ProcessCollection(); if (jobObject.AssignProcessToJobObject(process)) { - // Wait for the job object to finish - jobObject.WaitOne(_waithandle); + // Wait for the job object to finish, or kill it if a timeout occurs + jobObject.WaitOne(_waithandle, ExitTimeout); + if (!process.HasExited) + { + StopProcessOnTimeout(process); + } } else if (!process.HasExited) { // WinBlue: 27537 Start-Process -Wait doesn't work in a remote session on Windows 7 or lower. process.Exited += new EventHandler(myProcess_Exited); process.EnableRaisingEvents = true; - process.WaitForExit(); + if (!process.WaitForExit(ExitTimeout)) + { + StopProcessOnTimeout(process); + } } #endif } @@ -2123,6 +2142,127 @@ private void LoadEnvironmentVariable(ProcessStartInfo startinfo, IDictionary Env } } + /// + /// Attempt to stop the process when the timeout has expired. + /// is used to stop the process + /// + /// + /// The process that should be stopped + /// + private void StopProcessOnTimeout(Process process) + { + string message = StringUtil.Format(ProcessResources.StartProcessExitTimeoutExceeded, process.ProcessName); + ErrorRecord er = new ErrorRecord(new TimeoutException(message), "StartProcessExitTimeoutExceeded", ErrorCategory.OperationTimeout, process); + + StopProcessCommand stop = new StopProcessCommand(); + stop.Id = GetProcessTreeIds(process); + foreach (Process p in stop.Invoke()) { } + + ThrowTerminatingError(er); + } + + /// + /// Gets IDs of descendant processes, started by a process + /// On Windows, this reads output from WMI commands + /// On UNIX, this reads output from `ps axo pid,ppid` + /// + /// + /// The parent process to use to resolve the process tree IDs + /// + /// + /// IDs of the parent process and all its descendants + /// + private int[] GetProcessTreeIds(Process parentProcess) + { + List stopProcessIds = new List {parentProcess.Id}; + string processRelationships = ""; + bool processesCollected = true; +#if UNIX + try + { + Process ps = new Process(); + ps.StartInfo.FileName = "ps"; + ps.StartInfo.Arguments = "axo pid,ppid"; + ps.StartInfo.UseShellExecute = false; + ps.StartInfo.RedirectStandardOutput = true; + ps.Start(); + + processRelationships = ps.StandardOutput.ReadToEnd(); + + if (!ps.WaitForExit(4000) || ps.ExitCode != 0) + { + processesCollected = false; + } + ps.Close(); + } + catch (Win32Exception) + { + processesCollected = false; + } +#else + string searchQuery = "Select ProcessID, ParentProcessID From Win32_Process"; + try + { + using (CimSession cimSession = CimSession.Create(null)) + { + IEnumerable processCollection = + cimSession.QueryInstances("root/cimv2", "WQL", searchQuery); + StringBuilder sb = new StringBuilder(); + foreach (CimInstance processInstance in processCollection) + { + sb.Append(processInstance.CimInstanceProperties["ProcessID"].Value.ToString()); + sb.Append(' '); + sb.Append(processInstance.CimInstanceProperties["ParentProcessID"].Value.ToString()); + sb.Append(Environment.NewLine); + } + processRelationships = sb.ToString(); + } + } + catch (CimException) + { + processesCollected = false; + } +#endif + + if (!processesCollected) + { + WriteWarning( + StringUtil.Format(ProcessResources.CouldNotResolveProcessTree, parentProcess.ProcessName) + + " " + + ProcessResources.DescendantProcessesPossiblyRunning + ); + return stopProcessIds.ToArray(); + } + + // processList - key: process ID, value: parent process ID + Dictionary processList = new Dictionary(); + int pid = 0; + int ppid = 0; + string relationshipPattern = @"\s?([0-9]+)\s+([0-9]+)"; + foreach (Match psLine in Regex.Matches(processRelationships, relationshipPattern)) + { + pid = int.Parse(psLine.Groups[1].Value); + ppid = int.Parse(psLine.Groups[2].Value); + processList.Add(pid, ppid); + } + + int position = 0; + do + { + foreach (KeyValuePair process in processList) + { + if (process.Value == stopProcessIds[position]) + { + stopProcessIds.Add(process.Key); + } + } + + position++; + } while (position <= (stopProcessIds.Count - 1)); + + return stopProcessIds.ToArray(); + } + private Process Start(ProcessStartInfo startInfo) { #if UNIX @@ -2605,12 +2745,15 @@ internal void CheckJobStatus(Object stateInfo) /// /// WaitHandle to use for waiting on the job object. /// - internal void WaitOne(ManualResetEvent waitHandleToUse) + /// + /// Wait for this number of milliseconds before a time-out occurs. + /// + internal void WaitOne(ManualResetEvent waitHandleToUse, Int32 timeout = Timeout.Infinite) { TimerCallback jobObjectStatusCb = this.CheckJobStatus; using (Timer stateTimer = new Timer(jobObjectStatusCb, waitHandleToUse, 0, 1000)) { - waitHandleToUse.WaitOne(); + waitHandleToUse.WaitOne(timeout); } } } diff --git a/src/Microsoft.PowerShell.Commands.Management/resources/ProcessResources.resx b/src/Microsoft.PowerShell.Commands.Management/resources/ProcessResources.resx index e460ad2f016..d3083863eb5 100644 --- a/src/Microsoft.PowerShell.Commands.Management/resources/ProcessResources.resx +++ b/src/Microsoft.PowerShell.Commands.Management/resources/ProcessResources.resx @@ -129,6 +129,12 @@ Cannot stop process "{0} ({1})" because of the following error: {2} + + The process tree cannot be resolved for "{0}". + + + Descendant processes may still be running. + {0} ({1}) @@ -171,6 +177,9 @@ This command stopped operation because process "{0} ({1})" is not stopped in the specified time-out. + + This command stopped operation because process "{0}" did not exit within the specified time-out. + This command cannot be run due to the error: {0}. diff --git a/test/powershell/Modules/Microsoft.PowerShell.Management/Start-Process.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Management/Start-Process.Tests.ps1 index ba5f3583923..bc2714a7df3 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Management/Start-Process.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Management/Start-Process.Tests.ps1 @@ -116,6 +116,45 @@ Describe "Start-Process" -Tags @("Feature") { } } +Describe "Start-Process -Timeout" -Tags "Feature","Slow" { + + BeforeAll { + if ($IsWindows) { + $pingParam = "-n 30 localhost" + } + elseif ($IsLinux -Or $IsMacOS) { + $pingParam = "-c 30 localhost" + } + } + + It "Should work correctly if process completes before specified exit time-out" { + Start-Process ping -ArgumentList $pingParam -ExitTimeout 40000 -RedirectStandardOutput "$TESTDRIVE/output" | Should Be $null + } + + It "Should give an error when the specified exit time-out is exceeded" { + { Start-Process ping -ArgumentList $pingParam -ExitTimeout 20000 -RedirectStandardOutput "$TESTDRIVE/output" } | ShouldBeErrorId "StartProcessExitTimeoutExceeded,Microsoft.PowerShell.Commands.StartProcessCommand" + } + + It "Should use exit time-out value when both -ExitTimeout and -Wait are passed" { + { Start-Process ping -ArgumentList $pingParam -ExitTimeout 20000 -Wait -RedirectStandardOutput "$TESTDRIVE/output" } | ShouldBeErrorId "StartProcessExitTimeoutExceeded,Microsoft.PowerShell.Commands.StartProcessCommand" + } + + # This is based on the test "Should kill native process tree" in + # test\powershell\Language\Scripting\NativeExecution\NativeCommandProcessor.Tests.ps1 + It "Should stop any descendant processes when the specified exit time-out is exceeded" { + Get-Process testexe -ErrorAction SilentlyContinue | Stop-Process + + { Start-Process testexe -ArgumentList "-createchildprocess 6" -ExitTimeout 10000 -RedirectStandardOutput "$TESTDRIVE/output" } | ShouldBeErrorId "StartProcessExitTimeoutExceeded,Microsoft.PowerShell.Commands.StartProcessCommand" + + # Waiting for a second, as the $testexe processes may still be exiting + # and the Get-Process cmdlet will count them accidentally + Start-Sleep 1 + + $childprocesses = Get-Process testexe -ErrorAction SilentlyContinue + $childprocesses.count | Should Be 0 + } +} + Describe "Start-Process tests requiring admin" -Tags "Feature","RequireAdminOnWindows" { BeforeEach {