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 {