From 3ec05aeac522def03b7ccbfd1d322878ecaa1d08 Mon Sep 17 00:00:00 2001 From: Hal <22532613+HalRiveria@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:28:06 -0400 Subject: [PATCH 1/2] Stop a -ResultHandler without a closure from looping forever A -ResultHandler built without .GetNewClosure() sees Invoke-PkRunspaceJobs' locals by dynamic scope. The worker output it was being fed sat in a local named $results, so a handler doing $results.Add($r) added to the collection the kit was looping over, and the run never ended. Under Invoke-PkRun, Ctrl+C couldn't stop it either. The abort-drain path had the same loop, and a -JobCrashedHandler doing $results.Add($err) hit the kit's $null instead of the caller's list, with the error swallowed. Rename that local in both places so these callbacks reach the caller's own $results. Harness checks cover the normal path, the abort drain and the crash handler; all three fail without the fix. Co-Authored-By: Claude Opus 5.5 --- ParallelKit.Harness.ps1 | 49 +++++++++++++++++++++++++++++++++++++++++ ParallelKit.ps1 | 24 +++++++++++++------- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index 709ed2f..31d2197 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -924,6 +924,55 @@ try { } catch { $threw = ($_.ToString() -like "*job 2 supplies 'Typo'*Nothing was started*") } [System.Threading.Thread]::Sleep(300) Test-Assert ($threw -and $ran.Count -eq 0) ("a bad key in -JobParams is reported before any worker starts (workers run: " + $ran.Count + ")") + +# Callbacks built WITHOUT .GetNewClosure() see the kit's locals by dynamic +# scope. A caller's $results.Add($r) in one used to land in the kit's own +# worker-output collection, which the kit was looping over: an endless loop. +# The guard throws instead of hanging the harness if that comes back. +Reset-Harness +$results = [System.Collections.Generic.List[object]]::new() +$loopGuard = @{ Calls = 0 } +$threw = $null +try { + $null = Invoke-PkRunspaceJobs -MaxParallel 2 -JobParams (New-JobList -Jobs @(@{ Id = 1 }, @{ Id = 2 })) -WorkerScript { param([int]$Id) $Id } -ResultHandler { + param($r) + $loopGuard.Calls++ + if ($loopGuard.Calls -gt 20) { throw 'still looping after 20 handler calls' } + $results.Add($r) + } +} catch { $threw = $_.Exception.Message } +Test-Assert ($null -eq $threw -and $loopGuard.Calls -eq 2 -and $results.Count -eq 2) ("a -ResultHandler without a closure: `$results.Add reaches the caller's list and doesn't loop (calls=" + $loopGuard.Calls + ", count=" + $results.Count + ", threw=" + $threw + ")") + +# The same on the abort-drain path, which collects results separately. +Reset-Harness +$results = [System.Collections.Generic.List[object]]::new() +$loopGuard = @{ Calls = 0 } +$poll = @{ Count = 0 } +$threw = $null +try { + $null = Invoke-PkRunspaceJobs -MaxParallel 2 -JobParams (New-JobList -Jobs @(@{ Id = 1 }, @{ Id = 2 }, @{ Id = 3 })) -WorkerScript { + param([int]$Id) + # Finish only after the abort, and late enough that the main loop has + # already handed over to the drain. + for ($i = 0; $i -lt 100; $i++) { if (Test-PkCancelRequested) { break }; [System.Threading.Thread]::Sleep(50) } + [System.Threading.Thread]::Sleep(500) + return $Id + } -PollCallback { $poll.Count++; if ($poll.Count -eq 3) { Request-PkAbort } }.GetNewClosure() -ResultHandler { + param($r) + $loopGuard.Calls++ + if ($loopGuard.Calls -gt 20) { throw 'still looping after 20 handler calls' } + $results.Add($r) + } +} catch { $threw = $_.Exception.Message } +Test-Assert ($null -eq $threw -and $loopGuard.Calls -eq 2 -and $results.Count -eq 2) ("a -ResultHandler without a closure doesn't loop during an abort drain either (calls=" + $loopGuard.Calls + ", count=" + $results.Count + ", threw=" + $threw + ")") + +# A -JobCrashedHandler without a closure used to find the kit's $null +# $results instead of the caller's, and the error was swallowed. +Reset-Harness +$results = [System.Collections.Generic.List[object]]::new() +$null = Invoke-PkRunspaceJobs -MaxParallel 1 -JobParams (New-JobList -Jobs @(@{ Id = 1 })) -WorkerScript { param([int]$Id) throw "x" } -JobCrashedHandler { param($e) $results.Add($e) } +Test-Assert ($results.Count -eq 1) ("a -JobCrashedHandler without a closure reaches the caller's `$results (count=" + $results.Count + ")") +Reset-Harness Write-Host "" # ============================================================================== diff --git a/ParallelKit.ps1 b/ParallelKit.ps1 index 764405c..b1e11a9 100644 --- a/ParallelKit.ps1 +++ b/ParallelKit.ps1 @@ -1624,16 +1624,20 @@ function Receive-PkCompletedHandles { $stillActive = [System.Collections.Generic.List[hashtable]]::new() foreach ($h in $Handles) { if ($h.Handle.IsCompleted) { - $results = $null + # Not $results: a -ResultHandler built without .GetNewClosure() sees + # this function's locals, and $results.Add($r) there would grow the + # collection this loop walks, so it never ends. Same in + # Invoke-PkRunspaceJobs. + $pkWorkerOutput = $null try { - $results = $h.PS.EndInvoke($h.Handle) + $pkWorkerOutput = $h.PS.EndInvoke($h.Handle) } catch { if ($null -ne $ErrorHandler) { $null = & $ErrorHandler ("Error collecting runspace result during abort drain: " + $_) } } finally { $h.PS.Dispose() } - if ($null -ne $ResultHandler -and $null -ne $results) { - foreach ($r in $results) { $null = & $ResultHandler $r } + if ($null -ne $ResultHandler -and $null -ne $pkWorkerOutput) { + foreach ($r in $pkWorkerOutput) { $null = & $ResultHandler $r } } } else { $stillActive.Add($h) @@ -1920,9 +1924,13 @@ function Invoke-PkRunspaceJobs { $stillPending = [System.Collections.Generic.List[hashtable]]::new() foreach ($h in $handles) { if ($h.Handle.IsCompleted) { - $results = $null + # Not $results: a -ResultHandler or -JobCrashedHandler built + # without .GetNewClosure() sees this function's locals, and + # $results.Add($r) there would grow the collection this loop + # walks, so it never ends. Same in Receive-PkCompletedHandles. + $pkWorkerOutput = $null try { - $results = $h.PS.EndInvoke($h.Handle) + $pkWorkerOutput = $h.PS.EndInvoke($h.Handle) if ($h.PS.HadErrors) { foreach ($e in $h.PS.Streams.Error) { Invoke-PkMessageHandler -Message ("Runspace error: " + $e.ToString()) -Level Error } } @@ -1936,8 +1944,8 @@ function Invoke-PkRunspaceJobs { } finally { $h.PS.Dispose() } - if ($null -ne $results) { - foreach ($r in $results) { $null = & $ResultHandler $r } + if ($null -ne $pkWorkerOutput) { + foreach ($r in $pkWorkerOutput) { $null = & $ResultHandler $r } } } else { $stillPending.Add($h) From 4ede3a431d82899eb5aa9ef30e0ee1626c4fb2df Mon Sep 17 00:00:00 2001 From: Hal <22532613+HalRiveria@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:30:48 -0400 Subject: [PATCH 2/2] Check the drain-path test really runs through the drain Co-Authored-By: Claude Opus 5.5 --- ParallelKit.Harness.ps1 | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index 31d2197..fc6fb61 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -964,7 +964,10 @@ try { $results.Add($r) } } catch { $threw = $_.Exception.Message } -Test-Assert ($null -eq $threw -and $loopGuard.Calls -eq 2 -and $results.Count -eq 2) ("a -ResultHandler without a closure doesn't loop during an abort drain either (calls=" + $loopGuard.Calls + ", count=" + $results.Count + ", threw=" + $threw + ")") +# The wait message only appears when the drain had running jobs to collect, +# so this also proves the results came through the drain, not the main loop. +$drained = @($script:messages | Where-Object { $_.Message -like '*Waiting for current jobs*' }).Count -eq 1 +Test-Assert ($null -eq $threw -and $loopGuard.Calls -eq 2 -and $results.Count -eq 2 -and $drained) ("a -ResultHandler without a closure doesn't loop during an abort drain either (calls=" + $loopGuard.Calls + ", count=" + $results.Count + ", drained=" + $drained + ", threw=" + $threw + ")") # A -JobCrashedHandler without a closure used to find the kit's $null # $results instead of the caller's, and the error was swallowed.