Behavioral Specification

Requirements

Formal behavioral contracts. Every code change is validated against these. All tests are mapped to requirement IDs.

10 Requirements Phase 1 + Phase 2
REQ-01 · Phase 1
PSInvocationSettings.Timeout Property

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.

xUnit: TestPSInvocationSettingsTimeoutDefaultIsInfinitexUnit: TestPSInvocationSettingsTimeoutCanBeSetxUnit: TestPSInvocationSettingsTimeoutZeroIsValid Pester: Timeout property default is InfiniteTimeSpanPester: Timeout property can be set to a finite valuePester: Timeout property zero is validPester: Timeout property can be reset to InfiniteTimeSpan PowerShell.cs
REQ-02 · Phase 2
Invoke() on Single Runspace Honors Timeout (Phase 2)

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).

xUnit: TestInvokeThrowsTimeoutExceptionWhenExceededxUnit: TestInvokeTimeoutExceptionMessageContainsTimeout Pester: Invoke with Timeout throws TimeoutException when command exceeds timeout Scenario: 01-basic-timeoutScenario: 08-nested-timeout PowerShell.cs
REQ-02a · Phase 2
Invoke() with InfiniteTimeSpan Is Unchanged

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.

xUnit: TestInvokeCompletesWithinTimeoutxUnit: TestInvokeDefaultTimeoutNeverExpires Pester: Invoke with default InfiniteTimeSpan completes fast commandsPester: Invoke with default timeout does not expire for quick commands Scenario: 01-basic-timeout PowerShell.cs
REQ-03 · Phase 1
BatchInvocationContext.Wait Honors Timeout

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.

PowerShell.cs
REQ-04 · Phase 1
RunspacePool Acquisition Respects Timeout

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.

xUnit: TestPoolAcquisitionTimeoutThrowsxUnit: TestPoolAcquisitionSucceedsWithinTimeout Pester: Pool exhaustion throws TimeoutException with bounded waitPester: Pool with capacity succeeds within timeout Scenario: 07-pool-exhaustion PowerShell.cs
REQ-05 · Phase 1
Stop(TimeSpan) Stops Within Timeout or Throws

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.

xUnit: TestStopWithTimeoutOverloadCompletesxUnit: TestStopWithoutTimeoutRemainsBackwardsCompatiblexUnit: TestStopTimeoutExceptionMessageIsNonEmptyxUnit: TestStopAfterDisposeIsSilentxUnit: TestConcurrentStopAndInvokeNoDeadlock Pester: Stop(TimeSpan) stops a running command within the boundPester: Original Stop() overload still worksPester: Stop(TimeSpan) after Dispose is silent Scenario: 02-stop-timeout-overloadScenario: 06-concurrent-stop-and-invoke PowerShell.cs
REQ-06 · Phase 1
StopPipelines() Uses Parallel Execution

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).

xUnit: TestRunspaceCloseStopsPipelinesWithinBoundxUnit: TestRunspaceCloseMultiplePipelinesCompletesInBound Pester: 3 active pipelines all close within 120s deadline Scenario: 03-dispose-while-runningScenario: 05-runspace-close-with-pipeline ConnectionBase.cs
REQ-07 · Phase 1
LocalPipeline.Stop() Bounded to 30 Seconds

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.

Scenario: 05-runspace-close-with-pipeline LocalPipeline.cs
REQ-08 · Phase 1
Dispose() Swallows TimeoutException, Forces Broken State

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.

xUnit: TestDisposeWithRunningPipelineDoesNotHangxUnit: TestStopAfterDisposeIsSilent Pester: Dispose with active pipeline completes within 60s Scenario: 03-dispose-while-runningScenario: 04-double-dispose LocalConnection.cs
REQ-09 · Phase 2
Runspace Usable After Invoke() TimeoutException

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.

xUnit: TestRunspaceRemainsUsableAfterInvokeTimeout Pester: Runspace is usable after Invoke() TimeoutException Scenario: 01-basic-timeoutScenario: 08-nested-timeout PowerShell.cs
REQ-10 · Phase 2
Nested PS Timeout Propagates as MethodInvocationException

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.

xUnit: TestNestedPSTimeoutPropagates Scenario: 08-nested-timeout PowerShell.cs

Known Limitations

KL-01
STA COM Apartment State
When 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).
KL-02
CancellationToken Not Supported
This PR does not introduce a CancellationToken parameter. Cooperative cancellation integration is deferred to a follow-up RFC.
KL-03
Runspace Reuse Drain Delay
After an 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.
KL-04
Internal 30-Second Caps Are Not Configurable
The 30-second bounds on internal waits (LocalPipeline.Stop, StopPipelines, LocalConnection.Close) are compile-time constants, not exposed via settings. Configurable internal caps are out of scope for this PR.