diff --git a/src/System.Management.Automation/engine/hostifaces/ConnectionBase.cs b/src/System.Management.Automation/engine/hostifaces/ConnectionBase.cs
index 0969d7b08c6..30497af520e 100644
--- a/src/System.Management.Automation/engine/hostifaces/ConnectionBase.cs
+++ b/src/System.Management.Automation/engine/hostifaces/ConnectionBase.cs
@@ -378,15 +378,26 @@ private void CoreClose(bool syncCall)
// Wait till the runspace is opened - This is set in DoOpenHelper()
// Release the lock before we wait
Monitor.Exit(SyncRoot);
+ bool opened = false;
try
{
- RunspaceOpening.Wait();
+ opened = RunspaceOpening.Wait(TimeSpan.FromSeconds(30));
}
finally
{
// Acquire the lock before we carry on with the rest operations
Monitor.Enter(SyncRoot);
}
+
+ if (!opened)
+ {
+ SetRunspaceState(RunspaceState.Broken,
+ new TimeoutException(
+ StringUtil.Format(RunspaceStrings.StopPipelinesTimedOut,
+ TimeSpan.FromSeconds(30))));
+ RaiseRunspaceStateEvents();
+ return;
+ }
}
if (_bSessionStateProxyCallInProgress)
@@ -960,7 +971,14 @@ internal bool WaitForFinishofPipelines()
tuple.Item2.Set();
}),
stateInfo);
- return waitAllIsDone.WaitOne();
+ if (!waitAllIsDone.WaitOne(TimeSpan.FromSeconds(30)))
+ {
+ throw new TimeoutException(
+ StringUtil.Format(RunspaceStrings.StopPipelinesTimedOut,
+ TimeSpan.FromSeconds(30)));
+ }
+
+ return true;
}
}
@@ -976,6 +994,14 @@ internal bool WaitForFinishofPipelines()
/// Stops all the running pipelines.
///
protected void StopPipelines()
+ {
+ StopPipelines(System.Threading.Timeout.InfiniteTimeSpan);
+ }
+
+ ///
+ /// Stops all the running pipelines with a bounded timeout using parallel stop.
+ ///
+ protected void StopPipelines(TimeSpan timeout)
{
PipelineBase[] runningPipelines;
@@ -986,10 +1012,19 @@ protected void StopPipelines()
if (runningPipelines.Length > 0)
{
- // Start from the most recent pipeline.
- for (int i = runningPipelines.Length - 1; i >= 0; i--)
+ System.Threading.Tasks.Task[] stopTasks =
+ new System.Threading.Tasks.Task[runningPipelines.Length];
+ for (int i = 0; i < runningPipelines.Length; i++)
+ {
+ int index = i;
+ stopTasks[i] = System.Threading.Tasks.Task.Run(
+ () => runningPipelines[index].Stop());
+ }
+
+ if (!System.Threading.Tasks.Task.WaitAll(stopTasks, timeout))
{
- runningPipelines[i].Stop();
+ throw new TimeoutException(
+ StringUtil.Format(RunspaceStrings.StopPipelinesTimedOut, timeout));
}
}
}
diff --git a/src/System.Management.Automation/engine/hostifaces/LocalConnection.cs b/src/System.Management.Automation/engine/hostifaces/LocalConnection.cs
index 822dc552204..0147f3e9105 100644
--- a/src/System.Management.Automation/engine/hostifaces/LocalConnection.cs
+++ b/src/System.Management.Automation/engine/hostifaces/LocalConnection.cs
@@ -953,7 +953,12 @@ private static void CloseOrDisconnectAllRemoteRunspaces(Func
diff --git a/src/System.Management.Automation/engine/hostifaces/PowerShell.cs b/src/System.Management.Automation/engine/hostifaces/PowerShell.cs
index 3af978ea165..6726a210cd2 100644
--- a/src/System.Management.Automation/engine/hostifaces/PowerShell.cs
+++ b/src/System.Management.Automation/engine/hostifaces/PowerShell.cs
@@ -412,6 +412,12 @@ public bool ExposeFlowControlExceptions
/// layer supports this operation.
///
internal bool InvokeAndDisconnect { get; set; }
+
+ ///
+ /// Maximum time to wait for synchronous operations (Invoke, Stop, Close).
+ /// Default is Timeout.InfiniteTimeSpan which preserves backwards compatibility.
+ ///
+ public TimeSpan Timeout { get; set; } = System.Threading.Timeout.InfiniteTimeSpan;
}
///
@@ -451,6 +457,18 @@ internal void Wait()
_completionEvent.WaitOne();
}
+ ///
+ /// Waits for the completion event with a timeout.
+ ///
+ internal void Wait(TimeSpan timeout)
+ {
+ if (!_completionEvent.WaitOne(timeout))
+ {
+ throw new TimeoutException(
+ StringUtil.Format(PowerShellStrings.OperationTimedOut, timeout));
+ }
+ }
+
///
/// Signals the completion event.
///
@@ -3078,7 +3096,7 @@ public IAsyncResult BeginInvoke(PSDataCollection input,
///
///
/// The running PowerShell pipeline was stopped.
- /// This occurs when or is called.
+ /// This occurs when or is called.
///
public Task> InvokeAsync()
=> Task>.Factory.FromAsync(BeginInvoke(), _endInvokeMethod);
@@ -3123,7 +3141,7 @@ public Task> InvokeAsync()
///
///
/// The running PowerShell pipeline was stopped.
- /// This occurs when or is called.
+ /// This occurs when or is called.
///
public Task> InvokeAsync(PSDataCollection input)
=> Task>.Factory.FromAsync(BeginInvoke(input), _endInvokeMethod);
@@ -3181,7 +3199,7 @@ public Task> InvokeAsync(PSDataCollection input
///
///
/// The running PowerShell pipeline was stopped.
- /// This occurs when or is called.
+ /// This occurs when or is called.
///
public Task> InvokeAsync(PSDataCollection input, PSInvocationSettings settings, AsyncCallback callback, object state)
=> Task>.Factory.FromAsync(BeginInvoke(input, settings, callback, state), _endInvokeMethod);
@@ -3233,7 +3251,7 @@ public Task> InvokeAsync(PSDataCollection input
///
///
/// The running PowerShell pipeline was stopped.
- /// This occurs when or is called.
+ /// This occurs when or is called.
/// To collect partial output in this scenario,
/// supply a for the parameter,
/// and either add a handler for the event
@@ -3303,7 +3321,7 @@ public Task> InvokeAsync(PSDataColle
///
///
/// The running PowerShell pipeline was stopped.
- /// This occurs when or is called.
+ /// This occurs when or is called.
/// To collect partial output in this scenario,
/// supply a for the parameter,
/// and either add a handler for the event
@@ -3573,7 +3591,11 @@ private void DoRemainingBatchCommands(PSDataCollection objs)
// Queue a batch work item here.
// Calling CoreInvokeAsync / CoreInvoke here directly doesn't work and causes the thread to not respond.
ThreadPool.QueueUserWorkItem(new WaitCallback(BatchInvocationWorkItem), context);
- context.Wait();
+ TimeSpan batchTimeout = _batchInvocationSettings?.Timeout ?? System.Threading.Timeout.InfiniteTimeSpan;
+ if (batchTimeout == System.Threading.Timeout.InfiniteTimeSpan)
+ context.Wait();
+ else
+ context.Wait(batchTimeout);
}
}
}
@@ -3677,7 +3699,7 @@ private void AppendExceptionToErrorStream(Exception e)
///
///
/// The running PowerShell pipeline was stopped.
- /// This occurs when or is called.
+ /// This occurs when or is called.
/// To collect partial output in this scenario,
/// supply a to for the parameter
/// and either add a handler for the event
@@ -3755,6 +3777,29 @@ public void Stop()
}
}
+ ///
+ /// Stop the currently running command with a bounded timeout.
+ ///
+ /// Maximum time to wait for stop to complete.
+ /// Thrown if stop does not complete within timeout.
+ public void Stop(TimeSpan timeout)
+ {
+ try
+ {
+ IAsyncResult asyncResult = CoreStop(true, null, null);
+ if (!asyncResult.AsyncWaitHandle.WaitOne(timeout))
+ {
+ throw new TimeoutException(
+ StringUtil.Format(PowerShellStrings.StopTimedOut, timeout));
+ }
+
+ ResetOutputBufferAsNeeded();
+ }
+ catch (ObjectDisposedException)
+ {
+ }
+ }
+
///
/// Stop the currently running command asynchronously. If the command is not started,
/// the state of PowerShell instance is changed to Stopped and corresponding events
@@ -4521,7 +4566,13 @@ private void CoreInvokeHelper(PSDataCollection input, P
// getting the runspace asynchronously so that Stop can be supported from a different
// thread.
_worker.GetRunspaceAsyncResult = pool.BeginGetRunspace(null, null);
- _worker.GetRunspaceAsyncResult.AsyncWaitHandle.WaitOne();
+ TimeSpan poolTimeout = settings?.Timeout ?? System.Threading.Timeout.InfiniteTimeSpan;
+ if (!_worker.GetRunspaceAsyncResult.AsyncWaitHandle.WaitOne(poolTimeout))
+ {
+ throw new TimeoutException(
+ StringUtil.Format(PowerShellStrings.OperationTimedOut, poolTimeout));
+ }
+
rsToUse = pool.EndGetRunspace(_worker.GetRunspaceAsyncResult);
}
else
@@ -4547,8 +4598,39 @@ private void CoreInvokeHelper(PSDataCollection input, P
}
}
- // perform the work in the current thread
- _worker.CreateRunspaceIfNeededAndDoWork(rsToUse, true);
+ // perform the work in the current thread (Phase 2: bounded when Timeout is set)
+ TimeSpan invokeTimeout = settings?.Timeout ?? System.Threading.Timeout.InfiniteTimeSpan;
+ if (invokeTimeout == System.Threading.Timeout.InfiniteTimeSpan)
+ {
+ // Backwards-compatible fast path: zero overhead, no extra allocation.
+ _worker.CreateRunspaceIfNeededAndDoWork(rsToUse, true);
+ }
+ else
+ {
+ // Bounded path: spin invocation on a ThreadPool thread; join with timeout.
+ // Note: Task.Run dispatches to an MTA thread. Scripts relying on STA COM
+ // apartment state should use the InfiniteTimeSpan (default) path instead.
+ var invokeTask = System.Threading.Tasks.Task.Run(
+ () => _worker.CreateRunspaceIfNeededAndDoWork(rsToUse, true));
+ bool invokeCompleted;
+ try
+ {
+ invokeCompleted = invokeTask.Wait(invokeTimeout);
+ }
+ catch (AggregateException ae) when (ae.InnerExceptions.Count == 1)
+ {
+ // Unwrap so callers see the real exception type (e.g. RuntimeException).
+ System.Runtime.ExceptionServices.ExceptionDispatchInfo
+ .Capture(ae.InnerExceptions[0]).Throw();
+ throw; // unreachable; satisfies compiler
+ }
+ if (!invokeCompleted)
+ {
+ CoreStop(true, null, null); // best-effort: signal pipeline to stop
+ throw new TimeoutException(
+ StringUtil.Format(PowerShellStrings.OperationTimedOut, invokeTimeout));
+ }
+ }
}
else
{
@@ -5146,7 +5228,11 @@ private IAsyncResult CoreStop(bool isSyncCall, AsyncCallback callback, object st
if (isSyncCall)
{
- _stopAsyncResult.AsyncWaitHandle.WaitOne();
+ if (!_stopAsyncResult.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(30)))
+ {
+ throw new TimeoutException(
+ StringUtil.Format(PowerShellStrings.StopTimedOut, TimeSpan.FromSeconds(30)));
+ }
}
}
else
diff --git a/src/System.Management.Automation/resources/PowerShellStrings.resx b/src/System.Management.Automation/resources/PowerShellStrings.resx
index 563479c2d62..7868339f50a 100644
--- a/src/System.Management.Automation/resources/PowerShellStrings.resx
+++ b/src/System.Management.Automation/resources/PowerShellStrings.resx
@@ -177,4 +177,10 @@
A PowerShell object cannot be created that uses the current runspace because there is no current runspace available. The current runspace might be starting, such as when it is created with an Initial Session State.
+
+ The operation did not complete within the allotted time of {0}.
+
+
+ Stop did not complete within the allotted time of {0}.
+
diff --git a/src/System.Management.Automation/resources/RunspaceStrings.resx b/src/System.Management.Automation/resources/RunspaceStrings.resx
index e3a0f93bd7e..3c869e033cb 100644
--- a/src/System.Management.Automation/resources/RunspaceStrings.resx
+++ b/src/System.Management.Automation/resources/RunspaceStrings.resx
@@ -240,4 +240,7 @@
The static PrimaryRunspace property can only be set once, and has already been set.
+
+ One or more pipelines did not stop within the allotted time of {0}.
+
diff --git a/test/powershell/engine/Api/Timeout.Tests.ps1 b/test/powershell/engine/Api/Timeout.Tests.ps1
new file mode 100644
index 00000000000..f12ca21addf
--- /dev/null
+++ b/test/powershell/engine/Api/Timeout.Tests.ps1
@@ -0,0 +1,302 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+#
+# Pester tests for PowerShell Hosting API bounded-wait / timeout support.
+# Spec reference: SPECIFICATION.md
+# Tag "CI" = runs in standard CI pipeline
+# Tag "Feature" = longer-running tests that verify behavioural contracts
+
+Describe 'PowerShell Hosting Timeout Support' -Tag "CI" {
+
+ # ─────────────────────────────────────────────────────────────────────
+ # REQ-01 PSInvocationSettings.Timeout property
+ # ─────────────────────────────────────────────────────────────────────
+ Context 'PSInvocationSettings.Timeout property' {
+
+ It 'defaults to InfiniteTimeSpan' {
+ # REQ-01
+ $settings = [System.Management.Automation.PSInvocationSettings]::new()
+ $settings.Timeout | Should -Be ([System.Threading.Timeout]::InfiniteTimeSpan)
+ }
+
+ It 'can be set to a finite value' {
+ # REQ-01
+ $settings = [System.Management.Automation.PSInvocationSettings]::new()
+ $settings.Timeout = [TimeSpan]::FromSeconds(5)
+ $settings.Timeout.TotalSeconds | Should -Be 5
+ }
+
+ It 'zero timeout is a valid edge-case value' {
+ # REQ-01 edge: TimeSpan.Zero is valid (fires immediately)
+ $settings = [System.Management.Automation.PSInvocationSettings]::new()
+ $settings.Timeout = [TimeSpan]::Zero
+ $settings.Timeout | Should -Be ([TimeSpan]::Zero)
+ }
+
+ It 'can be reset to InfiniteTimeSpan explicitly' {
+ # REQ-01: round-trip set + reset
+ $settings = [System.Management.Automation.PSInvocationSettings]::new()
+ $settings.Timeout = [TimeSpan]::FromSeconds(3)
+ $settings.Timeout = [System.Threading.Timeout]::InfiniteTimeSpan
+ $settings.Timeout | Should -Be ([System.Threading.Timeout]::InfiniteTimeSpan)
+ }
+ }
+
+ # ─────────────────────────────────────────────────────────────────────
+ # REQ-02 / REQ-09 Invoke() on single Runspace honors Timeout (Phase 2)
+ # ─────────────────────────────────────────────────────────────────────
+ Context 'Invoke with Timeout (single runspace)' {
+
+ It 'completes fast script before timeout — no exception' {
+ # REQ-02a
+ $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
+ $rs.Open()
+ try {
+ $ps = [powershell]::Create()
+ $ps.Runspace = $rs
+ $ps.AddScript('1 + 1') > $null
+ $settings = [System.Management.Automation.PSInvocationSettings]::new()
+ $settings.Timeout = [TimeSpan]::FromSeconds(10)
+ $r = $ps.Invoke($null, $settings)
+ $r.Count | Should -Be 1
+ [int]$r[0] | Should -Be 2
+ $ps.Dispose()
+ } finally { $rs.Dispose() }
+ }
+
+ It 'throws TimeoutException when timeout exceeded' {
+ # REQ-02
+ $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
+ $rs.Open()
+ try {
+ $ps = [powershell]::Create()
+ $ps.Runspace = $rs
+ $ps.AddScript('Start-Sleep -Seconds 60') > $null
+ $settings = [System.Management.Automation.PSInvocationSettings]::new()
+ $settings.Timeout = [TimeSpan]::FromSeconds(2)
+ $threw = $false
+ try {
+ $ps.Invoke($null, $settings)
+ }
+ catch {
+ $threw = $true
+ $inner = $_.Exception
+ while ($inner -is [System.Management.Automation.MethodInvocationException] -or
+ $inner -is [System.Reflection.TargetInvocationException]) {
+ $inner = $inner.InnerException
+ }
+ $inner | Should -BeOfType ([TimeoutException])
+ }
+ $threw | Should -BeTrue -Because 'Invoke should throw TimeoutException'
+ $ps.Dispose()
+ } finally { $rs.Dispose() }
+ }
+
+ It 'default InfiniteTimeSpan never causes TimeoutException' {
+ # REQ-02a — verify the default path does not inject overhead
+ $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
+ $rs.Open()
+ try {
+ $ps = [powershell]::Create()
+ $ps.Runspace = $rs
+ $ps.AddScript('"ok"') > $null
+ # No settings.Timeout set — uses default (infinite)
+ $r = $ps.Invoke()
+ $r[0] | Should -BeExactly 'ok'
+ $ps.Dispose()
+ } finally { $rs.Dispose() }
+ }
+
+ It 'runspace is reusable after TimeoutException' {
+ # REQ-09
+ $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
+ $rs.Open()
+ try {
+ $ps1 = [powershell]::Create()
+ $ps1.Runspace = $rs
+ $ps1.AddScript('Start-Sleep -Seconds 60') > $null
+ $settings = [System.Management.Automation.PSInvocationSettings]::new()
+ $settings.Timeout = [TimeSpan]::FromSeconds(2)
+ try { $ps1.Invoke($null, $settings) > $null } catch [TimeoutException] { Write-Verbose "Expected timeout" }
+ $ps1.Dispose()
+
+ # Brief pause for the stopped pipeline to drain before reuse.
+ Start-Sleep -Milliseconds 600
+
+ $ps2 = [powershell]::Create()
+ $ps2.Runspace = $rs
+ $ps2.AddScript('99') > $null
+ $r = $ps2.Invoke()
+ [int]$r[0] | Should -Be 99
+ $ps2.Dispose()
+ } finally { $rs.Dispose() }
+ }
+ }
+
+ # ─────────────────────────────────────────────────────────────────────
+ # REQ-05 Stop(TimeSpan) overload
+ # ─────────────────────────────────────────────────────────────────────
+ Context 'Stop(TimeSpan) overload' {
+
+ It 'stops a running command within timeout' {
+ # REQ-05
+ $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
+ $rs.Open()
+ try {
+ $ps = [powershell]::Create()
+ $ps.Runspace = $rs
+ $ps.AddScript('Start-Sleep -Seconds 60') > $null
+ $null = $ps.BeginInvoke()
+ Start-Sleep -Milliseconds 200
+ $ps.Stop([TimeSpan]::FromSeconds(10))
+ $ps.InvocationStateInfo.State | Should -Be 'Stopped'
+ $ps.Dispose()
+ } finally { $rs.Dispose() }
+ }
+
+ It 'original Stop() overload still works unchanged' {
+ # REQ-05: backwards compatibility
+ $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
+ $rs.Open()
+ try {
+ $ps = [powershell]::Create()
+ $ps.Runspace = $rs
+ $ps.AddScript('Start-Sleep -Seconds 60') > $null
+ $null = $ps.BeginInvoke()
+ Start-Sleep -Milliseconds 200
+ $ps.Stop()
+ $ps.InvocationStateInfo.State | Should -Be 'Stopped'
+ $ps.Dispose()
+ } finally { $rs.Dispose() }
+ }
+
+ It 'Stop after Dispose is silent — no exception' {
+ # REQ-08b: Stop(TimeSpan) on a disposed PS object must not throw
+ $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
+ $rs.Open()
+ try {
+ $ps = [powershell]::Create()
+ $ps.Runspace = $rs
+ $ps.Dispose()
+ { $ps.Stop([TimeSpan]::FromSeconds(5)) } | Should -Not -Throw
+ } finally { $rs.Dispose() }
+ }
+ }
+
+ # ─────────────────────────────────────────────────────────────────────
+ # REQ-04 RunspacePool exhaustion
+ # ─────────────────────────────────────────────────────────────────────
+ Context 'RunspacePool exhaustion' {
+
+ It 'TimeoutException when pool is exhausted' {
+ # REQ-04
+ $pool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1, 1)
+ $pool.Open()
+ try {
+ $ps1 = [powershell]::Create()
+ $ps1.RunspacePool = $pool
+ $ps1.AddScript('Start-Sleep -Seconds 30') > $null
+ $null = $ps1.BeginInvoke()
+ Start-Sleep -Milliseconds 300
+
+ $ps2 = [powershell]::Create()
+ $ps2.RunspacePool = $pool
+ $ps2.AddScript('1') > $null
+ $settings = [System.Management.Automation.PSInvocationSettings]::new()
+ $settings.Timeout = [TimeSpan]::FromSeconds(2)
+
+ $threw = $false
+ try {
+ $ps2.Invoke($null, $settings)
+ }
+ catch {
+ $threw = $true
+ $inner = $_.Exception
+ while ($inner -is [System.Management.Automation.MethodInvocationException] -or
+ $inner -is [System.Reflection.TargetInvocationException]) {
+ $inner = $inner.InnerException
+ }
+ $inner | Should -BeOfType ([TimeoutException])
+ }
+ $threw | Should -BeTrue -Because 'Invoke should throw TimeoutException on exhausted pool'
+
+ $ps1.Stop(); $ps1.Dispose()
+ $ps2.Dispose()
+ } finally { $pool.Close(); $pool.Dispose() }
+ }
+
+ It 'succeeds when pool has capacity' {
+ # REQ-04a
+ $pool = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspacePool(1, 3)
+ $pool.Open()
+ try {
+ $ps = [powershell]::Create()
+ $ps.RunspacePool = $pool
+ $ps.AddScript('42') > $null
+ $settings = [System.Management.Automation.PSInvocationSettings]::new()
+ $settings.Timeout = [TimeSpan]::FromSeconds(10)
+ $r = $ps.Invoke($null, $settings)
+ $r.Count | Should -Be 1
+ [int]$r[0] | Should -Be 42
+ $ps.Dispose()
+ } finally { $pool.Close(); $pool.Dispose() }
+ }
+ }
+
+ # ─────────────────────────────────────────────────────────────────────
+ # REQ-06 Parallel StopPipelines
+ # ─────────────────────────────────────────────────────────────────────
+ Context 'Parallel StopPipelines' {
+
+ It 'Close with 3 active pipelines completes within 120s' -Tag "Feature" {
+ # REQ-06: parallel stop is faster than sequential N*single
+ $job = Start-Job {
+ $runspaces = @()
+ $psList = @()
+ for ($i = 0; $i -lt 3; $i++) {
+ $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
+ $rs.Open()
+ $ps = [powershell]::Create()
+ $ps.Runspace = $rs
+ $ps.AddScript('Start-Sleep -Seconds 300') > $null
+ $null = $ps.BeginInvoke()
+ $runspaces += $rs
+ $psList += $ps
+ }
+ $tasks = $runspaces | ForEach-Object { $rs = $_; [System.Threading.Tasks.Task]::Run([System.Action]{ $rs.Close() }) }
+ [System.Threading.Tasks.Task]::WaitAll($tasks)
+ foreach ($ps in $psList) { $ps.Dispose() }
+ return $true
+ }
+ $result = $job | Wait-Job -Timeout 120
+ $result | Should -Not -BeNullOrEmpty -Because "3-runspace close should finish within 120s"
+ Receive-Job $job | Should -BeTrue
+ Remove-Job $job -Force
+ }
+ }
+
+ # ─────────────────────────────────────────────────────────────────────
+ # REQ-08 Dispose does not hang
+ # ─────────────────────────────────────────────────────────────────────
+ Context 'Dispose safety' {
+
+ It 'Dispose with running pipeline completes within 60s' -Tag "Feature" {
+ # REQ-08
+ $job = Start-Job {
+ $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
+ $rs.Open()
+ $ps = [powershell]::Create()
+ $ps.Runspace = $rs
+ $ps.AddScript('Start-Sleep -Seconds 300') > $null
+ $null = $ps.BeginInvoke()
+ $ps.Dispose()
+ $rs.Dispose()
+ return $true
+ }
+ $result = $job | Wait-Job -Timeout 60
+ $result | Should -Not -BeNullOrEmpty -Because "Dispose should not hang indefinitely"
+ Receive-Job $job | Should -BeTrue
+ Remove-Job $job -Force
+ }
+ }
+}
diff --git a/test/xUnit/csharp/TimeoutTests.cs b/test/xUnit/csharp/TimeoutTests.cs
new file mode 100644
index 00000000000..208535badc8
--- /dev/null
+++ b/test/xUnit/csharp/TimeoutTests.cs
@@ -0,0 +1,437 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Management.Automation;
+using System.Management.Automation.Runspaces;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace PSTests.Sequential
+{
+ ///
+ /// Tests for PSInvocationSettings.Timeout, PowerShell.Stop(TimeSpan),
+ /// bounded runspace close, and parallel StopPipelines.
+ ///
+ ///
+ /// Tests that may leave a pipeline in a stopped-but-draining state use their own
+ /// private Runspace to avoid contaminating the shared fixture runspace.
+ ///
+ public class TimeoutTests : IDisposable
+ {
+ // Shared runspace for tests that do not exercise timeout paths.
+ private readonly Runspace _runspace;
+
+ public TimeoutTests()
+ {
+ _runspace = RunspaceFactory.CreateRunspace();
+ _runspace.Open();
+ }
+
+ public void Dispose() => _runspace?.Dispose();
+
+ // ─────────────────────────────────────────────────────────────────────
+ // REQ-01 PSInvocationSettings.Timeout property
+ // ─────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void TestPSInvocationSettingsTimeoutDefaultIsInfinite()
+ {
+ // REQ-01: default MUST be InfiniteTimeSpan for backwards compatibility.
+ var settings = new PSInvocationSettings();
+ Assert.Equal(Timeout.InfiniteTimeSpan, settings.Timeout);
+ }
+
+ [Fact]
+ public void TestPSInvocationSettingsTimeoutCanBeSet()
+ {
+ // REQ-01: property is read/write.
+ var settings = new PSInvocationSettings { Timeout = TimeSpan.FromSeconds(5) };
+ Assert.Equal(TimeSpan.FromSeconds(5), settings.Timeout);
+ }
+
+ [Fact]
+ public void TestPSInvocationSettingsTimeoutZeroIsValid()
+ {
+ // REQ-01: zero is a valid edge-case value (fires immediately).
+ var settings = new PSInvocationSettings { Timeout = TimeSpan.Zero };
+ Assert.Equal(TimeSpan.Zero, settings.Timeout);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // REQ-02 Invoke() on single Runspace honors Timeout (Phase 2)
+ // ─────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void TestInvokeCompletesWithinTimeout()
+ {
+ // REQ-02a: fast command finishes before timeout — no exception thrown.
+ using var ps = PowerShell.Create();
+ ps.Runspace = _runspace;
+ ps.AddScript("1 + 1");
+ var settings = new PSInvocationSettings { Timeout = TimeSpan.FromSeconds(10) };
+ var results = ps.Invoke(null, settings);
+ Assert.Single(results);
+ Assert.Equal(2, (int)results[0].BaseObject);
+ }
+
+ [Fact]
+ public void TestInvokeDefaultTimeoutNeverExpires()
+ {
+ // REQ-02a: when Timeout == InfiniteTimeSpan (default), no TimeoutException.
+ using var ps = PowerShell.Create();
+ ps.Runspace = _runspace;
+ ps.AddScript("'hello'");
+ var settings = new PSInvocationSettings();
+ Assert.Equal(Timeout.InfiniteTimeSpan, settings.Timeout);
+ var results = ps.Invoke(null, settings);
+ Assert.Single(results);
+ Assert.Equal("hello", (string)results[0].BaseObject);
+ }
+
+ [Fact]
+ public void TestInvokeThrowsTimeoutExceptionWhenExceeded()
+ {
+ // REQ-02: slow command MUST throw TimeoutException when Timeout elapses.
+ // Uses private runspace so the pipeline stop does not affect other tests.
+ using var rs = NewRunspace();
+ using var ps = PowerShell.Create();
+ ps.Runspace = rs;
+ ps.AddScript("Start-Sleep -Seconds 60");
+ var settings = new PSInvocationSettings { Timeout = TimeSpan.FromSeconds(2) };
+ Assert.Throws(() => ps.Invoke(null, settings));
+ }
+
+ [Fact]
+ public void TestInvokeTimeoutExceptionMessageContainsTimeout()
+ {
+ // REQ-02: exception message must not be empty and must reference the timeout.
+ using var rs = NewRunspace();
+ using var ps = PowerShell.Create();
+ ps.Runspace = rs;
+ ps.AddScript("Start-Sleep -Seconds 60");
+ var settings = new PSInvocationSettings { Timeout = TimeSpan.FromSeconds(2) };
+ var ex = Assert.Throws(() => ps.Invoke(null, settings));
+ Assert.False(string.IsNullOrEmpty(ex.Message));
+ }
+
+ [Fact]
+ public void TestRunspaceRemainsUsableAfterInvokeTimeout()
+ {
+ // REQ-09: a runspace MUST be usable again after a TimeoutException.
+ using var rs = NewRunspace();
+
+ using (var ps1 = PowerShell.Create())
+ {
+ ps1.Runspace = rs;
+ ps1.AddScript("Start-Sleep -Seconds 60");
+ var settings = new PSInvocationSettings { Timeout = TimeSpan.FromSeconds(2) };
+ Assert.Throws(() => ps1.Invoke(null, settings));
+ }
+
+ // Wait briefly for the stopped pipeline to fully drain.
+ Thread.Sleep(500);
+
+ using var ps2 = PowerShell.Create();
+ ps2.Runspace = rs;
+ ps2.AddScript("1 + 2");
+ var results = ps2.Invoke();
+ Assert.Single(results);
+ Assert.Equal(3, (int)results[0].BaseObject);
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // REQ-05 Stop(TimeSpan) overload
+ // ─────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void TestStopWithTimeoutOverloadCompletes()
+ {
+ // REQ-05: Stop(TimeSpan) stops a running command and sets state to Stopped.
+ using var rs = NewRunspace();
+ using var ps = PowerShell.Create();
+ ps.Runspace = rs;
+ ps.AddScript("Start-Sleep -Seconds 60");
+ ps.BeginInvoke();
+ Thread.Sleep(200);
+ ps.Stop(TimeSpan.FromSeconds(10));
+ Assert.Equal(PSInvocationState.Stopped, ps.InvocationStateInfo.State);
+ }
+
+ [Fact]
+ public void TestStopWithoutTimeoutRemainsBackwardsCompatible()
+ {
+ // REQ-05: original Stop() overload MUST still work unchanged.
+ using var rs = NewRunspace();
+ using var ps = PowerShell.Create();
+ ps.Runspace = rs;
+ ps.AddScript("Start-Sleep -Seconds 60");
+ ps.BeginInvoke();
+ Thread.Sleep(200);
+ ps.Stop();
+ Assert.Equal(PSInvocationState.Stopped, ps.InvocationStateInfo.State);
+ }
+
+ [Fact]
+ public void TestStopTimeoutExceptionMessageIsNonEmpty()
+ {
+ // REQ-05: if Stop(TimeSpan) itself times out, message must be non-empty.
+ using var rs = NewRunspace();
+ using var ps = PowerShell.Create();
+ ps.Runspace = rs;
+ ps.AddScript("Start-Sleep -Seconds 60");
+ ps.BeginInvoke();
+ Thread.Sleep(200);
+
+ // TimeSpan.Zero forces immediate timeout without waiting for real stall.
+ try
+ {
+ ps.Stop(TimeSpan.Zero);
+ }
+ catch (TimeoutException ex)
+ {
+ Assert.False(string.IsNullOrEmpty(ex.Message));
+ }
+
+ // If Stop completed (race), that is also acceptable.
+ }
+
+ [Fact]
+ public void TestStopAfterDisposeIsSilent()
+ {
+ // REQ-08b: Stop(TimeSpan) after Dispose() MUST NOT throw ObjectDisposedException.
+ using var rs = NewRunspace();
+ var ps = PowerShell.Create();
+ ps.Runspace = rs;
+ ps.Dispose();
+ ps.Stop(TimeSpan.FromSeconds(5)); // must be silent
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // REQ-04 RunspacePool acquisition respects Timeout
+ // ─────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void TestPoolAcquisitionTimeoutThrows()
+ {
+ // REQ-04: pool size=1 occupied by sleeping ps1; ps2 must throw TimeoutException.
+ using var pool = RunspaceFactory.CreateRunspacePool(1, 1);
+ pool.Open();
+
+ var ps1 = PowerShell.Create();
+ ps1.RunspacePool = pool;
+ ps1.AddScript("Start-Sleep -Seconds 30");
+ ps1.BeginInvoke();
+ Thread.Sleep(300); // let ps1 acquire the only slot
+
+ using var ps2 = PowerShell.Create();
+ ps2.RunspacePool = pool;
+ ps2.AddScript("1");
+ var settings = new PSInvocationSettings { Timeout = TimeSpan.FromSeconds(2) };
+
+ try
+ {
+ Assert.Throws(() => ps2.Invoke(null, settings));
+ }
+ finally
+ {
+ ps1.Stop();
+ ps1.Dispose();
+ pool.Close();
+ }
+ }
+
+ [Fact]
+ public void TestPoolAcquisitionSucceedsWithinTimeout()
+ {
+ // REQ-04a: pool has capacity; acquisition completes before the timeout.
+ using var pool = RunspaceFactory.CreateRunspacePool(1, 3);
+ pool.Open();
+ try
+ {
+ using var ps = PowerShell.Create();
+ ps.RunspacePool = pool;
+ ps.AddScript("42");
+ var settings = new PSInvocationSettings { Timeout = TimeSpan.FromSeconds(10) };
+ var results = ps.Invoke(null, settings);
+ Assert.Single(results);
+ Assert.Equal(42, (int)results[0].BaseObject);
+ }
+ finally
+ {
+ pool.Close();
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // REQ-06 / REQ-07 Bounded runspace close + parallel StopPipelines
+ // ─────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void TestRunspaceCloseStopsPipelinesWithinBound()
+ {
+ // REQ-06/REQ-07: Close() with one active pipeline completes within bound.
+ using var rs = NewRunspace();
+ using var ps = PowerShell.Create();
+ ps.Runspace = rs;
+ ps.AddScript("Start-Sleep -Seconds 300");
+ ps.BeginInvoke();
+
+ var closeTask = Task.Run(() => rs.Close());
+ bool completed = closeTask.Wait(TimeSpan.FromSeconds(60));
+ Assert.True(completed, "Runspace.Close() should complete within 60s");
+ }
+
+ [Fact]
+ public void TestRunspaceCloseMultiplePipelinesCompletesInBound()
+ {
+ // REQ-06: 3 runspaces each with an active sleep pipeline — all close within cap.
+ const int count = 3;
+ var runspaces = new Runspace[count];
+ var psList = new List();
+
+ for (int i = 0; i < count; i++)
+ {
+ runspaces[i] = NewRunspace();
+ var ps = PowerShell.Create();
+ ps.Runspace = runspaces[i];
+ ps.AddScript("Start-Sleep -Seconds 300");
+ ps.BeginInvoke();
+ psList.Add(ps);
+ }
+
+ var closeTasks = new Task[count];
+ for (int i = 0; i < count; i++)
+ {
+ int idx = i;
+ closeTasks[idx] = Task.Run(() => runspaces[idx].Close());
+ }
+
+ bool allDone = Task.WaitAll(closeTasks, TimeSpan.FromSeconds(120));
+ Assert.True(allDone, "All runspaces should close within 120s");
+
+ foreach (var ps in psList)
+ {
+ ps.Dispose();
+ }
+
+ foreach (var rs in runspaces)
+ {
+ rs.Dispose();
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // REQ-08 Dispose() does not hang
+ // ─────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void TestDisposeWithRunningPipelineDoesNotHang()
+ {
+ // REQ-08: Dispose() MUST complete within a bounded time even with active pipelines.
+ using var rs = NewRunspace();
+ var ps = PowerShell.Create();
+ ps.Runspace = rs;
+ ps.AddScript("Start-Sleep -Seconds 300");
+ ps.BeginInvoke();
+
+ var disposeTask = Task.Run(() =>
+ {
+ ps.Dispose();
+ rs.Dispose();
+ });
+
+ bool completed = disposeTask.Wait(TimeSpan.FromSeconds(60));
+ Assert.True(completed, "Dispose() should complete within 60s");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // REQ-10 Nested PS timeout propagation
+ // ─────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void TestNestedPSTimeoutPropagates()
+ {
+ // REQ-10: TimeoutException from nested PS Invoke must propagate upward.
+ using var rs = NewRunspace();
+ using var ps = PowerShell.Create();
+ ps.Runspace = rs;
+ ps.AddScript(@"
+ $rs2 = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
+ $rs2.Open()
+ $inner = [powershell]::Create()
+ $inner.Runspace = $rs2
+ $inner.AddScript('Start-Sleep -Seconds 60') > $null
+ $settings = [System.Management.Automation.PSInvocationSettings]::new()
+ $settings.Timeout = [TimeSpan]::FromSeconds(2)
+ try {
+ $inner.Invoke($null, $settings) > $null
+ } finally {
+ $rs2.Dispose(); $inner.Dispose()
+ }
+ ");
+
+ var ex = Record.Exception(() => ps.Invoke());
+ Assert.NotNull(ex);
+ var isTimeout = ex is TimeoutException ||
+ (ex is System.Management.Automation.MethodInvocationException &&
+ ex.InnerException is TimeoutException);
+ Assert.True(
+ isTimeout,
+ $"Expected TimeoutException (possibly wrapped), got: {ex.GetType().Name}: {ex.Message}");
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // REQ-05 Concurrent Stop + Invoke — no deadlock
+ // ─────────────────────────────────────────────────────────────────────
+ [Fact]
+ public void TestConcurrentStopAndInvokeNoDeadlock()
+ {
+ // REQ-05: Stop(TimeSpan) and Invoke() racing from two threads MUST NOT deadlock.
+ using var rs = NewRunspace();
+ using var ps = PowerShell.Create();
+ ps.Runspace = rs;
+ ps.AddScript("Start-Sleep -Seconds 60");
+
+ Exception invokeEx = null, stopEx = null;
+
+ var invokeTask = Task.Run(() =>
+ {
+ try
+ {
+ ps.Invoke();
+ }
+ catch (System.Management.Automation.PipelineStoppedException)
+ {
+ // expected
+ }
+ catch (Exception ex)
+ {
+ invokeEx = ex;
+ }
+ });
+
+ var stopTask = Task.Run(() =>
+ {
+ Thread.Sleep(300);
+ try
+ {
+ ps.Stop(TimeSpan.FromSeconds(10));
+ }
+ catch (Exception ex)
+ {
+ stopEx = ex;
+ }
+ });
+
+ bool allDone = Task.WaitAll(new[] { invokeTask, stopTask }, TimeSpan.FromSeconds(20));
+ Assert.True(allDone, "Concurrent Stop+Invoke should resolve within 20s — no deadlock");
+ Assert.Null(stopEx);
+ Assert.Null(invokeEx);
+ }
+
+ // Helper: create + open an isolated runspace.
+ private static Runspace NewRunspace()
+ {
+ var rs = RunspaceFactory.CreateRunspace();
+ rs.Open();
+ return rs;
+ }
+ }
+}