Indeed, the current semantics of the -TimeoutSeconds parameter of ForEach-Object's PowerShell (Core) 7+ -Parallel feature are unfortunate (as of PowerShell 7.3.6). To spell out your observation in more detail:
The -TimeoutSeconds interval is applied to the duration of the overall, typically throttled and therefore "batched" invocation[1] rather than to the runtime of each thread.
Therefore, a timeout can occur even if all individual threads completed in less than the specified timeout; a simple example:
# This times out when the 3rd thread runs, because - due to ThrottleLimit 2 -
# it only starts after 1.5+ seconds, after the first 2 threads
# have finished.
1..4 |
ForEach-Object -TimeoutSeconds 2 -ThrottleLimit 2 -Parallel {
Start-Sleep -MilliSeconds 1500
$_ # Pass the input object through.
}
When a timeout occurs, the command terminates overall.
- This means that threads for any remaining pipeline input then never even get to launch.
- In the above example, only
1 and 2 print; input 4 never got processed, because processing of 3 caused the timeout.
GitHub issue #20197 asks for these shortcomings to be addressed.
As a - somewhat cumbersome - workaround, you can use the -AsJob parameter to make ForEach-Object return a job whose child jobs represent the individual threads, which can be monitored separately.
Applied to a slightly modified version of the example above that provokes a timeout for the 3rd input object:
# Use -AsJob to receive a job that allows monitoring the threads individually.
# Note that -AsJob cannot be combined with -TimeoutSeconds
$job =
1..4 |
ForEach-Object -AsJob -ThrottleLimit 2 -Parallel {
if ($_ -eq 3) {
# Provoke a timeout error for this specific input.
Start-Sleep -MilliSeconds 2500; $_
} else {
Start-Sleep -MilliSeconds 1500; $_
}
}
# Receive job output in a polling loop, and terminate child jobs
# that have run too long.
$timeout = 2
do {
Start-Sleep -Milliseconds 500 # Sleep a little.
# Get pending results.
$job | Receive-Job
# If any child jobs have been running for more than N seconds,
# stop (terminate) them.
# This will open up slots for more threads to spin up.
foreach ($childJob in $job.ChildJobs.Where({ $_.State -eq 'Running' })) {
if (([datetime]::now - $childJob.PSBeginTime).TotalSeconds -ge $timeout) {
Write-Verbose -Verbose "Stopping job with ID $($childJob.Id) due to running longer than $timeout seconds..."
$childJob | Stop-Job
}
}
} while ($job.ChildJobs.Where({ $_.State -in 'NotStarted', 'Running' }))
Output:
1
2
4
VERBOSE: Stopping job with ID 4 due to running longer than 2 seconds...
Note:
Input 4 was still processed, despite the thread for input 3 having timed out.
The ID value of the child job isn't really meaningful except to distinguish it from other child jobs; if you want to know what input object caused the timeout, you'll have to echo it as part of the script block (at the start, before a timeout can occur) - the job object doesn't contain this information.
[1] More accurately, only a fixed number of threads are allowed to run at a time, based on the -ThrottleLimit arguments, which defaults to 5. If more threads are needed, they have to wait until "slots" open up, which happens when currently executing threads finish.