From c50585a74ae5fad8d7454bf7f10c19d6798eed3e Mon Sep 17 00:00:00 2001 From: SufficientDaikon <65625347+SufficientDaikon@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:36:11 +0200 Subject: [PATCH] Add RFC for hosting API bounded waits --- .../RFCNNNN-Hosting-API-Bounded-Waits.md | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 Draft-Accepted/RFCNNNN-Hosting-API-Bounded-Waits.md diff --git a/Draft-Accepted/RFCNNNN-Hosting-API-Bounded-Waits.md b/Draft-Accepted/RFCNNNN-Hosting-API-Bounded-Waits.md new file mode 100644 index 00000000..1f744645 --- /dev/null +++ b/Draft-Accepted/RFCNNNN-Hosting-API-Bounded-Waits.md @@ -0,0 +1,238 @@ +--- +RFC: RFCNNNN +Author: Taha Ahmad +Status: Draft +SupercededBy: N/A +Version: 1.0 +Area: Engine +Comments Due: 2026-05-12 +Plan to implement: Yes +--- + +# Bounded-Wait Timeout Support for PowerShell Hosting API + +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. + +## Motivation + + 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. + +### Real-world scenarios + +| 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 | + +## User Experience + +### Setting a timeout on Invoke + +```csharp +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 results = ps.Invoke(null, settings); +} +catch (TimeoutException ex) +{ + Console.WriteLine($"Script timed out: {ex.Message}"); + // The runspace is still usable for subsequent invocations. +} +``` + +```output +Script timed out: The operation did not complete within the allotted time of 00:00:10. +``` + +### Bounded Stop + +```csharp +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."); +} +``` + +### Default behavior (unchanged) + +```csharp +// 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. +``` + +## Specification + +### New public API surface + +#### `PSInvocationSettings.Timeout` property + +```csharp +/// +/// 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; +``` + +| Attribute | Value | +|-----------|-------| +| Type | `System.TimeSpan` | +| Default | `System.Threading.Timeout.InfiniteTimeSpan` | +| Valid range | Any `TimeSpan >= TimeSpan.Zero`, or `InfiniteTimeSpan` | +| Exception on set | None | + +#### `PowerShell.Stop(TimeSpan)` overload + +```csharp +/// +/// 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); +``` + +- Initiates stop via `CoreStop(true, null, null)` and waits on the returned + `AsyncWaitHandle` with a bounded timeout +- Waits on `asyncResult.AsyncWaitHandle.WaitOne(timeout)` +- Throws `TimeoutException` if the deadline elapses +- Swallows `ObjectDisposedException` (calling after `Dispose()` is safe) + +### Invoke with finite Timeout (single Runspace path) + +When `settings.Timeout` is finite and the PowerShell instance is bound to a +single `Runspace` (not a `RunspacePool`): + +1. The invocation is dispatched to a `ThreadPool` thread via `Task.Run`. +2. The calling thread joins with `invokeTask.Wait(timeout)`. +3. **If the task completes:** results are returned normally. Any exception from + the invocation is unwrapped from `AggregateException` and rethrown as-is. +4. **If the deadline elapses:** `CoreStop(true, null, null)` is called + (best-effort pipeline stop), then `TimeoutException` is thrown. + +When `settings.Timeout` is `InfiniteTimeSpan` (default), the invocation runs +directly on the calling thread with zero overhead -- identical to existing +behavior. + +### Invoke with finite Timeout (RunspacePool path) + +When the PowerShell instance uses a `RunspacePool`: + +1. `pool.BeginGetRunspace(null, null)` is called. +2. `AsyncWaitHandle.WaitOne(settings.Timeout)` bounds the pool slot wait. +3. If the slot is not available in time: `TimeoutException` is thrown. + +### Internal bounded waits (not public API) + +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 | + +### STA COM caveat + +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`. + +### Runspace reuse after timeout + +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`). + +## Alternate Proposals and Considerations + +### CancellationToken-based API + +A `CancellationToken` parameter on `Invoke()` would be more idiomatic for +modern .NET async patterns. However: + +- `Invoke()` is synchronous. A `CancellationToken` parameter on a sync method + is unusual and the cancellation semantics (cooperative vs. forced) would need + careful design. +- `Task.Run` + `Wait(timeout)` + `CoreStop` provides 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. + +### Configurable internal caps + +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. + +### Experimental feature gating + +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. + +### No-op Stop after Dispose + +`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. + +## Implementation + +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/