From 6b221910cc57938c196c1ffdfa00c9d004fa26a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Jare=C5=A1?= Date: Mon, 24 Aug 2026 22:13:10 +0200 Subject: [PATCH] Fail the parallel run when a worker returns no result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four files went into a Run.Parallel run on CI and three came back. The missing file was not failed, skipped or errored, it was not reported at all, and the run looked green with one test fewer than it should have had. Invoke-TestInParallel filters worker results with a Where-Object that keeps only well formed result objects, to guard against stray pipeline output. That filter cannot tell stray output from a worker that died before returning its result, and nothing afterwards compared the number of results to the number of files sent. So a lost file was dropped as quietly as a stray string, and Sort-Object put the survivors in discovery order, which made the output look like a normal shorter run. Compare the counts after the filter and throw, naming the files that went missing. The worker's own error is already surfaced by Invoke-InRunspacePool, this says which file it cost us. Losing results silently is worse than failing. This is independent of why the worker died. Any worker that dies for any reason costs a whole test file, and #3004 only stopped one failing worker from aborting the results of the others. Added a test that hands Invoke-TestInParallel a runspace pool runner which drops one file's result, and asserts the run throws and names that file. Fix #3011 🤖 --- src/functions/Pester.Parallel.ps1 | 18 +++++++++++ tst/Pester.RSpec.Parallel.ts.ps1 | 51 +++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src/functions/Pester.Parallel.ps1 b/src/functions/Pester.Parallel.ps1 index 0b9c3634d..4030d176a 100644 --- a/src/functions/Pester.Parallel.ps1 +++ b/src/functions/Pester.Parallel.ps1 @@ -478,6 +478,24 @@ function Invoke-TestInParallel { # Keep only well-formed worker results (defensive against stray pipeline output). $results = @($results | & $SafeCommands['Where-Object'] { $_ -is [System.Management.Automation.PSCustomObject] -and $null -ne $_.PSObject.Properties['Containers'] }) + # The filter above cannot tell stray output from a worker that died before it returned its + # result object, so on its own it would drop a whole test file and let the run report success + # with fewer files than it was given. Losing results silently is worse than failing, so compare + # what came back against what was sent and name the files that went missing. The worker's own + # error was already surfaced by Invoke-InRunspacePool, this says which file it cost us. + if ($results.Count -ne $work.Count) { + $returnedPaths = @{} + foreach ($r in $results) { + if ($null -ne $r.PSObject.Properties['Path']) { $returnedPaths[$r.Path] = $true } + } + + $missing = @(foreach ($w in $work) { + if (-not $returnedPaths.ContainsKey($w.Path)) { $w.Path } + }) + + throw "Parallel run lost the results of $($missing.Count) of $($work.Count) file(s), the worker(s) running them did not return a result. See the errors above for why. Lost: $($missing -join ', ')" + } + # Restore the original discovery order so replay and the merged run are deterministic # regardless of which worker finished first. $order = @{} diff --git a/tst/Pester.RSpec.Parallel.ts.ps1 b/tst/Pester.RSpec.Parallel.ts.ps1 index 37e15f828..ad0e0cecc 100644 --- a/tst/Pester.RSpec.Parallel.ts.ps1 +++ b/tst/Pester.RSpec.Parallel.ts.ps1 @@ -317,6 +317,57 @@ Describe 'Second' { } } + b "Lost worker results" { + t "fails the run instead of returning fewer files than it was given" { + # A worker that dies before returning its result object leaves nothing for the + # well-formed-result filter to keep, so without the count check the run would report + # success for the files that did come back and never mention the one that did not. + # Losing a test file silently is worse than failing, so this must throw and name it. + $folder = Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().Guid) + $null = New-Item -ItemType Directory -Path $folder -Force + foreach ($name in 'A', 'B', 'C') { + Set-Content -Path (Join-Path $folder "$name.Tests.ps1") -Value "Describe '$name' { It 'i' { 1 | Should -Be 1 } }" + } + + try { + # Stand in for a worker that died: hand Invoke-TestInParallel a runspace-pool + # runner that drops B's result on the floor and returns the other two. + $err = & (Get-Module Pester) { + param ($Root) + + $original = ${function:Invoke-InRunspacePool} + ${function:Invoke-InRunspacePool} = { + param ($InputObject, $ScriptBlock, $ThrottleLimit, $ItemParameterName = 'item', $Parameters = @{}) + foreach ($i in $InputObject) { + if ($i.Path -like '*B.Tests.ps1') { continue } + [PSCustomObject]@{ Path = $i.Path; Containers = @(); Tape = @(); Coverage = $null } + } + } + + try { + $c = [PesterConfiguration]::Default + $c.Run.Path = $Root + $c.Run.Parallel = $true + $c.Output.Verbosity = 'None' + $containers = @(Find-File -Path $Root -Extension '.Tests.ps1' | ForEach-Object { New-BlockContainerObject -File $_ }) + + try { + $null = Invoke-TestInParallel -BlockContainer $containers -Configuration $c + $null + } + catch { $_.Exception.Message } + } + finally { ${function:Invoke-InRunspacePool} = $original } + } $folder + + $err | Verify-NotNull + $err | Verify-Like '*lost the results of 1 of 3 file(s)*' + $err | Verify-Like '*B.Tests.ps1*' + } + finally { Remove-Item -Path $folder -Recurse -Force } + } + } + b "Invoke-InRunspacePool" { # The parallelism primitive Run.Parallel is built on. It replaces ForEach-Object -Parallel, # which does not exist on Windows PowerShell 5.1, so these run on both editions and are the