Requirements
Formal behavioral contracts. Every code change is validated against these. All tests are mapped to requirement IDs.
SHALL expose a Timeout property of type System.TimeSpan on PSInvocationSettings.
Default: System.Threading.Timeout.InfiniteTimeSpan — zero change to any existing behaviour.
Preconditions: None.
Postconditions: The property value is readable and writable. Any TimeSpan value (including TimeSpan.Zero and InfiniteTimeSpan) is accepted without validation exception.
Exception contract: Setting the property never throws.
SHALL throw System.TimeoutException when PSInvocationSettings.Timeout is finite and Invoke() does not complete within that interval on a single-runspace invocation.
Preconditions: Invoke() is called with PSInvocationSettings where Timeout is a finite positive TimeSpan. The PowerShell instance has a single Runspace assigned (not a RunspacePool).
Postconditions: If the invocation exceeds Timeout, CoreStop() is called as a best-effort signal. A TimeoutException is thrown to the caller.
Exception contract: TimeoutException.Message is non-null and non-empty. The exception message contains a human-readable representation of the timeout duration.
Implementation note: Uses Task.Run + invokeTask.Wait(timeout). AggregateException from a faulted task is unwrapped so callers see the real exception type. STA COM caveat: Task.Run dispatches to a ThreadPool MTA thread — STA-dependent scripts must use InfiniteTimeSpan (the default).
SHALL execute Invoke() on the calling thread with zero overhead when PSInvocationSettings.Timeout equals System.Threading.Timeout.InfiniteTimeSpan.
Preconditions: Timeout is InfiniteTimeSpan (either explicitly set or by default).
Postconditions: _worker.CreateRunspaceIfNeededAndDoWork is called directly on the calling thread — no Task.Run, no AggregateException wrapping, no timer allocation. Byte-for-byte identical to pre-fix behaviour.
Rationale: Backwards compatibility. Any existing code that does not set Timeout continues to work exactly as before, with zero additional overhead.
SHALL bound the BatchInvocationContext.Wait() call using PSInvocationSettings.Timeout when a finite timeout is set.
Preconditions: Invoke() uses the batch invocation path (e.g., multiple statements processed via BatchInvocationWorkItem). PSInvocationSettings.Timeout is set to a finite TimeSpan.
Postconditions: context.Wait(batchTimeout) is called instead of context.Wait(). If the batch does not complete within batchTimeout, a TimeoutException is thrown.
Fast path: If Timeout is InfiniteTimeSpan, context.Wait() is called unchanged.
SHALL throw TimeoutException when waiting for a RunspacePool slot and PSInvocationSettings.Timeout expires before a slot is available.
Preconditions: PowerShell is configured with a RunspacePool. A Timeout is set. All slots in the pool are occupied.
Postconditions: GetRunspaceAsyncResult.AsyncWaitHandle.WaitOne(timeout) returns false. TimeoutException is thrown. The pool and existing invocations are left undisturbed.
Exception contract: TimeoutException.Message is non-null and contains the timeout duration.
SHALL expose a Stop(TimeSpan timeout) method on PowerShell that signals stop and waits up to timeout for the pipeline to finish.
Preconditions: PowerShell instance is in Running or Stopping state, or any state including Disposed.
Postconditions (success): Invocation state is Stopped. The method returns normally.
Postconditions (timeout): TimeoutException is thrown. The pipeline may still be stopping asynchronously.
Exception contract: ObjectDisposedException is silently swallowed — calling Stop(TimeSpan) on a disposed instance is a no-op.
Backwards compat: The existing parameterless Stop() overload is unchanged.
SHALL stop all running pipelines in parallel (not sequentially) with an aggregate timeout.
Preconditions: StopPipelines(TimeSpan) is called with one or more active pipelines.
Postconditions: One Task.Run(Stop) is launched per pipeline concurrently. Task.WaitAll(tasks, timeout) joins all tasks. Total wall-clock time is bounded by timeout, not N × 30s.
Exception contract: If Task.WaitAll returns false (timeout), TimeoutException is thrown with the aggregate timeout value.
Performance: With N pipelines and 30s per-pipeline bound, old behaviour was O(N×30s) worst case. New behaviour is O(30s) worst case (all pipelines stop in the same window).
SHALL bound the PipelineFinishedEvent.WaitOne() call in LocalPipeline.Stop() to 30 seconds.
Preconditions: LocalPipeline.Stop() has been called. The pipeline’s stopper has been signalled.
Postconditions (success): PipelineFinishedEvent is set within 30 seconds. Method returns normally.
Postconditions (timeout): TimeoutException is thrown after 30s.
Rationale: Previously WaitOne() was unbounded — a pipeline that failed to stop cleanly (e.g., stuck managed thread) would hang Stop() forever.
SHALL catch TimeoutException thrown from Close() inside Dispose() and force the runspace into Broken state instead of propagating the exception.
Preconditions: Dispose() is called on a LocalConnection/LocalRunspace. Close() may throw TimeoutException if StopPipelines or a wait handle times out.
Postconditions: No exception escapes Dispose(). Runspace state is set to Broken with the TimeoutException as the reason. Resources are released.
Rationale: Dispose() must never throw (IDisposable contract). The Broken state signal allows embedders to detect that the runspace was abandoned due to a timeout — not silently.
SHALL leave the Runspace in a usable state after an Invoke() call throws TimeoutException.
Preconditions: Invoke() threw TimeoutException. The worker thread may still be finishing its stop in the background.
Postconditions: After a brief drain period (~500ms), a new PowerShell instance on the same Runspace can execute a command and return a result.
Known limitation (KL-03): A short drain delay is required after timeout. Immediate re-use on the same runspace may fail if the background worker thread has not yet finished stopping. See KL-03 in Known Limitations.
SHALL surface timeout conditions from nested PowerShell invocations as either TimeoutException or MethodInvocationException wrapping a TimeoutException.
Preconditions: An outer PowerShell invocation runs a script that transitions to an inner PowerShell.BeginInvoke(). The outer invocation’s Timeout expires.
Postconditions: CoreStop() fires on the outer instance. The inner pipeline receives PipelineStoppedException. PowerShell’s reflection layer wraps it as MethodInvocationException. Either TimeoutException or MethodInvocationException (wrapping TimeoutException) is thrown to the caller.
Exception contract: The caller should catch both TimeoutException and MethodInvocationException when using timeouts with scripts that may create nested PowerShell instances.
Known Limitations
Timeout is set to a finite value, Invoke() dispatches to a
ThreadPool MTA thread via Task.Run.
Scripts that rely on STA COM objects must use InfiniteTimeSpan (the default).
CancellationToken parameter.
Cooperative cancellation integration is deferred to a follow-up RFC.
Invoke() timeout on a single runspace, a brief drain period
(~500ms) is needed before the same runspace is reused. The background worker thread may
still be finishing its stop when the TimeoutException is thrown to the caller.
LocalPipeline.Stop, StopPipelines,
LocalConnection.Close) are compile-time constants, not exposed via settings.
Configurable internal caps are out of scope for this PR.