| RFC | RFCNNNN |
|---|---|
| Author | Taha Ahmad |
| Status | Draft |
| SupercededBy | N/A |
| Version | 1.0 |
| Area | Engine |
| Comments Due | 2026-05-12 |
| Plan to implement | true |
This RFC proposes adding two public API members to the PowerShell hosting API
and adding bounded alternatives to the critical unbounded WaitOne()/Wait() calls in the pipeline path
with bounded alternatives. The changes are fully opt-in and backwards
compatible.
As a developer hosting PowerShell in a C# application,
I can set a timeout on PowerShell.Invoke() and PowerShell.Stop(),
so that a runaway or deadlocked script cannot hang my host process
indefinitely.
The PowerShell hosting API (System.Management.Automation.PowerShell) is used
by VS Code's PowerShell extension, Azure Functions, Azure Automation, Jupyter
notebooks, and thousands of custom C# applications. When a script hangs or
deadlocks, every WaitOne() call in the pipeline path blocks indefinitely.
The host has no way to recover short of killing its own process.
Since Thread.Abort does not exist in .NET Core, there is currently no
mechanism for a hosting application to impose a deadline on script execution
through the public API.
| Consumer | Problem today |
|---|---|
| VS Code PowerShell Extension | Must Process.Kill() when the integrated console hangs |
| Azure Functions | Stuck scripts hold RunspacePool slots forever, requiring app pool recycle |
| Custom C# hosts (CI/CD, management tools) | No timeout mechanism; stuck invocations require process restart |
| Jupyter / Polyglot Notebooks | Hung cell execution means killing the entire kernel |
using var rs = RunspaceFactory.CreateRunspace();
rs.Open();
using var ps = PowerShell.Create();
ps.Runspace = rs;
ps.AddScript("Start-Sleep -Seconds 300");
var settings = new PSInvocationSettings
{
Timeout = TimeSpan.FromSeconds(10)
};
try
{
// Returns normally if the script completes within 10 seconds.
// Throws TimeoutException if the deadline elapses.
Collection<PSObject> results = ps.Invoke(null, settings);
}
catch (TimeoutException ex)
{
Console.WriteLine($"Script timed out: {ex.Message}");
// The runspace is still usable for subsequent invocations.
}Script timed out: The operation did not complete within the allotted time of 00:00:10.
ps.AddScript("Start-Sleep -Seconds 300");
ps.BeginInvoke();
try
{
ps.Stop(TimeSpan.FromSeconds(5));
}
catch (TimeoutException)
{
Console.WriteLine("Stop did not complete in 5 seconds.");
}// No Timeout set — default is InfiniteTimeSpan.
// Behaves identically to previous versions.
var settings = new PSInvocationSettings();
ps.Invoke(null, settings); // Same-thread, no Task.Run, no overhead./// <summary>
/// Maximum time to wait for synchronous operations (Invoke, Stop, Close).
/// Default is Timeout.InfiniteTimeSpan which preserves backwards compatibility.
/// </summary>
public TimeSpan Timeout { get; set; } = System.Threading.Timeout.InfiniteTimeSpan;| Attribute | Value |
|---|---|
| Type | System.TimeSpan |
| Default | System.Threading.Timeout.InfiniteTimeSpan |
| Valid range | Any TimeSpan >= TimeSpan.Zero, or InfiniteTimeSpan |
| Exception on set | None |
/// <summary>
/// Stop the currently running command with a bounded timeout.
/// </summary>
/// <param name="timeout">Maximum time to wait for stop to complete.</param>
/// <exception cref="TimeoutException">Thrown if stop does not complete within timeout.</exception>
public void Stop(TimeSpan timeout);- Initiates stop via
CoreStop(true, null, null)and waits on the returnedAsyncWaitHandlewith a bounded timeout - Waits on
asyncResult.AsyncWaitHandle.WaitOne(timeout) - Throws
TimeoutExceptionif the deadline elapses - Swallows
ObjectDisposedException(calling afterDispose()is safe)
When settings.Timeout is finite and the PowerShell instance is bound to a
single Runspace (not a RunspacePool):
- The invocation is dispatched to a
ThreadPoolthread viaTask.Run. - The calling thread joins with
invokeTask.Wait(timeout). - If the task completes: results are returned normally. Any exception from
the invocation is unwrapped from
AggregateExceptionand rethrown as-is. - If the deadline elapses:
CoreStop(true, null, null)is called (best-effort pipeline stop), thenTimeoutExceptionis thrown.
When settings.Timeout is InfiniteTimeSpan (default), the invocation runs
directly on the calling thread with zero overhead -- identical to existing
behavior.
When the PowerShell instance uses a RunspacePool:
pool.BeginGetRunspace(null, null)is called.AsyncWaitHandle.WaitOne(settings.Timeout)bounds the pool slot wait.- If the slot is not available in time:
TimeoutExceptionis thrown.
These are correctness fixes that prevent indefinite hangs during cleanup:
| Location | What changed | Bound |
|---|---|---|
ConnectionBase.CoreClose |
Runspace-opening event wait | 30 s |
ConnectionBase.StopPipelines |
Now parallel (Task.Run + Task.WaitAll); per-pipeline bound via LocalPipeline.Stop |
per-pipeline (30 s) |
LocalConnection.Close/Dispose |
Job and close waits | 30 s |
LocalConnection.Dispose |
Catches TimeoutException, forces Broken state |
-- |
LocalPipeline.Stop |
PipelineFinishedEvent.WaitOne |
30 s |
When Timeout is finite, Invoke() uses Task.Run which dispatches to an
MTA thread. Scripts that depend on STA COM apartment state (legacy ActiveX,
Windows Forms ShowDialog) must leave Timeout at InfiniteTimeSpan.
After a TimeoutException from Invoke(), the Runspace remains usable. The
caller should allow a brief moment for the stopped pipeline to drain before
starting a new invocation (poll InvocationStateInfo.State or use a short
Thread.Sleep).
A CancellationToken parameter on Invoke() would be more idiomatic for
modern .NET async patterns. However:
Invoke()is synchronous. ACancellationTokenparameter on a sync method is unusual and the cancellation semantics (cooperative vs. forced) would need careful design.Task.Run+Wait(timeout)+CoreStopprovides deterministic forced shutdown, which is what hosting applications need.- A
CancellationToken-based API can be added in a future RFC without conflicting with this design.
The 30-second internal caps on LocalPipeline.Stop(), StopPipelines(), and
LocalConnection.Close() are hardcoded. A system-wide or per-runspace cap
setting could be added in a follow-up RFC. The 30-second default was chosen as
a conservative bound that is generous enough for any well-behaved pipeline but
prevents indefinite hangs.
The [Experimental] attribute system targets cmdlet parameters and visibility
toggles. PSInvocationSettings.Timeout is a plain property on a POCO class,
not a cmdlet parameter. Runtime gating (if (!IsEnabled) throw) is possible
but adds friction for a feature that is already opt-in by design. If the
Committee prefers experimental gating, we can add a PSHostingAPITimeout
feature flag.
Stop(TimeSpan) silently swallows ObjectDisposedException. This matches the
.NET pattern where calling cleanup methods on a disposed object should not
throw. The alternative (throwing) would force callers to wrap every Stop()
call in try/catch after using blocks, which is boilerplate with no benefit.
A reference implementation is available at PowerShell/PowerShell#27027 (6 modified files, 2 new test files, +913/-20 lines).
An interactive documentation site with full specification, execution flow diagrams, and 8 adversarial scenario walkthroughs is available at: https://ps-bounded-wait-docs.pages.dev/