From 37c98f2ad3a27f35f397f38069ac3c881e7ef281 Mon Sep 17 00:00:00 2001 From: Hal <22532613+HalRiveria@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:56:45 -0400 Subject: [PATCH] Return objects instead of hashtables from results Results came back as hashtables, so Format-Table showed Name/Value pairs instead of columns. Every result the kit hands back is now a [pscustomobject] with its fields in a fixed order: Invoke-PkProcessBatch (and each of its results), Invoke-PkProcessWithTimeout and Complete-PkProcess, Invoke-PkProcessDispatch (and each group result), Invoke-PkRunspaceJobs, Test-PkWorkerSafety findings and Initialize-ParallelKit. Reading fields with a dot ($r.ExitCode) works as before. Breaking: indexing ($r['ExitCode']), .ContainsKey(), and adding fields to a result by assignment no longer work. Job and command hashtables you pass in, and what Start-PkProcess returns, stay hashtables. The three places that built a batch result now share New-PkBatchResult. Co-Authored-By: Claude Opus 5.5 --- ParallelKit.Harness.ps1 | 31 ++++++++++--- ParallelKit.ps1 | 98 +++++++++++++++++++++++++---------------- README.md | 10 ++--- 3 files changed, 89 insertions(+), 50 deletions(-) diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index d2e55e7..c1b031f 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -332,7 +332,7 @@ $sw.Stop() Test-Assert ($dispatch.Aborted) "an abort mid-dispatch is reported as Aborted" Test-Assert ($dispatch.ForceKilledCount -eq 2) "the 2 running processes are force-killed after the abort timeout" Test-Assert ($cleaned.Count -eq 5) ("-JobAbortCleanup runs for killed AND never-started jobs (got " + $cleaned.Count + ")") -Test-Assert ($dispatch -is [hashtable]) "the dispatcher returns exactly one result object, even on the force-kill path" +Test-Assert ($dispatch -is [System.Management.Automation.PSCustomObject]) "the dispatcher returns exactly one result object, even on the force-kill path" Test-Assert ($dispatch.GroupResults.Count -eq 1 -and $dispatch.GroupResults[0].Aborted) "the unfinished group is reported as aborted" Test-Assert ($sw.Elapsed.TotalSeconds -lt 15) ("the dispatcher returns promptly (" + [int]$sw.Elapsed.TotalSeconds + "s), not after 30s of ping") Write-Host "" @@ -501,11 +501,11 @@ $out = Invoke-PkRunspaceJobs -MaxParallel 2 -JobParams (New-JobList -Jobs @(1..4 -WorkerScript { param([int]$Id) for ($i = 0; $i -lt 50; $i++) { if (Test-PkCancelRequested) { return $Id }; [System.Threading.Thread]::Sleep(100) }; return $Id } ` -ResultHandler { param($r) $true; 123 } ` -PollCallback { $poll.Count++; if ($poll.Count -eq 4) { Request-PkAbort }; "noise" }.GetNewClosure() -Test-Assert ($out -is [hashtable] -and $out.Aborted) "runspace jobs: noisy -ResultHandler/-PollCallback don't corrupt the result, even through an abort drain" +Test-Assert ($out -is [System.Management.Automation.PSCustomObject] -and $out.Aborted) "runspace jobs: noisy -ResultHandler/-PollCallback don't corrupt the result, even through an abort drain" Reset-Harness $crashOut = Invoke-PkRunspaceJobs -MaxParallel 1 -JobParams (New-JobList -Jobs @(@{ Id = 1 })) -WorkerScript { param($Id) throw "x" } -JobCrashedHandler { param($e) "noise"; 5 } -Test-Assert ($crashOut -is [hashtable] -and $crashOut.CrashedCount -eq 1) "runspace jobs: a noisy -JobCrashedHandler doesn't corrupt the result" +Test-Assert ($crashOut -is [System.Management.Automation.PSCustomObject] -and $crashOut.CrashedCount -eq 1) "runspace jobs: a noisy -JobCrashedHandler doesn't corrupt the result" Reset-Harness $feed = @{ Given = $false }; $ticks = @{ Count = 0 } @@ -516,11 +516,11 @@ $noisy = Invoke-PkProcessDispatch -MaxConcurrent 1 -AbortTimeoutSeconds 1 ` -JobAbortCleanup { param($job) "noise"; $true } ` -GroupCompleteCallback { param($group) "noise"; 1 } ` -AbortPollCallback { param($running) $ticks.Count++; if ($ticks.Count -eq 5) { Request-PkAbort }; "noise" }.GetNewClosure() -Test-Assert ($noisy -is [hashtable] -and $noisy.ForceKilledCount -eq 1) "dispatch: noisy -JobAbortCleanup/-GroupCompleteCallback/-AbortPollCallback don't corrupt the result" +Test-Assert ($noisy -is [System.Management.Automation.PSCustomObject] -and $noisy.ForceKilledCount -eq 1) "dispatch: noisy -JobAbortCleanup/-GroupCompleteCallback/-AbortPollCallback don't corrupt the result" Reset-Harness $noisyBatch = Invoke-PkProcessBatch -Commands @(@{ FilePath = $cmdExe; Arguments = '/c exit 0' }) -MaxConcurrent 1 -OnComplete { param($c, $r) "noise"; $true } -Test-Assert ($noisyBatch -is [hashtable] -and $noisyBatch.Results.Count -eq 1) "batch: a noisy -OnComplete doesn't corrupt the result" +Test-Assert ($noisyBatch -is [System.Management.Automation.PSCustomObject] -and $noisyBatch.Results.Count -eq 1) "batch: a noisy -OnComplete doesn't corrupt the result" Set-PkMessageHandler -Handler { param($Message, $Level) "noise"; 42 } $leaked = Write-PkMessage "hello" @@ -1132,18 +1132,35 @@ Test-Assert ([PkState]::SourceCount -eq 0) "and nothing is left registered" # Every batch result has every documented field, so a strict-mode caller # can read any of them without a "property cannot be found" error. Reset-Harness +# Results are objects with their fields in a fixed order, so Format-Table +# shows them readably. +function Get-HarnessShape($o) { if ($o -isnot [System.Management.Automation.PSCustomObject]) { return '(not an object)' }; return ($o.PSObject.Properties.Name -join ',') } +$shapeBatch = Invoke-PkProcessBatch -Commands @(@{ FilePath = $cmdExe; Arguments = '/c exit 0' }) +Test-Assert ((Get-HarnessShape $shapeBatch) -eq 'Results,Aborted,ForceKilledCount') ("a batch returns an object (" + (Get-HarnessShape $shapeBatch) + ")") +Test-Assert ((Get-HarnessShape $shapeBatch.Results[0]) -eq 'Command,ExitCode,Stdout,Stderr,StreamTimedOut,StreamError,StartError,Aborted,TimedOut') ("each batch result is an object, fields in order (" + (Get-HarnessShape $shapeBatch.Results[0]) + ")") +$shapeOne = Invoke-PkProcessWithTimeout -FilePath $cmdExe -Arguments '/c exit 0' +Test-Assert ((Get-HarnessShape $shapeOne) -eq 'ExitCode,Stdout,Stderr,StreamTimedOut,StreamError') ("Invoke-PkProcessWithTimeout returns an object (" + (Get-HarnessShape $shapeOne) + ")") +$shapeDispatch = Invoke-PkProcessDispatch -MaxConcurrent 1 -GroupProvider { return $null } -JobBuilder { param($g) } -JobFinalize { param($j, $p) } +Test-Assert ((Get-HarnessShape $shapeDispatch) -eq 'GroupResults,Aborted,ForceKilledCount,JobStarterFailedCount') ("the dispatcher returns an object (" + (Get-HarnessShape $shapeDispatch) + ")") +$shapeJobs = Invoke-PkRunspaceJobs -MaxParallel 1 -JobParams @{ Id = 1 } -WorkerScript { param($Id) } +Test-Assert ((Get-HarnessShape $shapeJobs) -eq 'ForceStoppedCount,CrashedCount,Aborted') ("runspace jobs return an object (" + (Get-HarnessShape $shapeJobs) + ")") +$shapeFindings = Test-PkWorkerSafety -WorkerScript { param($Id) $null = [Func[int]]{ 1 } } +$shapeFinding = $shapeFindings[0] +Test-Assert ((Get-HarnessShape $shapeFinding) -eq 'Rule,Where,Line,Text') ("worker-safety findings are objects (" + (Get-HarnessShape $shapeFinding) + ")") +Test-Assert ((Get-HarnessShape (Initialize-ParallelKit)) -eq 'AbortHandler,ChildProcessJob,ThreadPool') "Initialize-ParallelKit returns an object" + $fields = @('Command', 'ExitCode', 'Stdout', 'Stderr', 'StreamTimedOut', 'StreamError', 'StartError', 'Aborted', 'TimedOut') $batch = Invoke-PkProcessBatch -MaxConcurrent 1 -Commands @( @{ FilePath = $cmdExe; Arguments = '/c exit 0' }, @{ FilePath = 'C:\definitely\not\here.exe' }) -$missing = foreach ($r in $batch.Results) { foreach ($k in $fields) { if (-not $r.ContainsKey($k)) { $k } } } +$missing = foreach ($r in $batch.Results) { foreach ($k in $fields) { if ($r.PSObject.Properties.Name -notcontains $k) { $k } } } Test-Assert ($batch.Results.Count -eq 2 -and @($missing).Count -eq 0) ("batch results for a finished and an unstartable command carry every field (missing: " + (@($missing) -join ', ') + ")") # An abort already in force: nothing starts, every command comes back Aborted. Reset-Harness Request-PkAbort $batch = Invoke-PkProcessBatch -MaxConcurrent 1 -Commands @(@{ FilePath = $cmdExe; Arguments = '/c exit 0' }, @{ FilePath = $cmdExe; Arguments = '/c exit 0' }) -$missing = foreach ($r in $batch.Results) { foreach ($k in $fields) { if (-not $r.ContainsKey($k)) { $k } } } +$missing = foreach ($r in $batch.Results) { foreach ($k in $fields) { if ($r.PSObject.Properties.Name -notcontains $k) { $k } } } Test-Assert ($batch.Aborted -and $batch.Results.Count -eq 2 -and @($batch.Results | Where-Object { $_.Aborted }).Count -eq 2) "batch: commands an abort kept from starting come back marked Aborted" Test-Assert (@($missing).Count -eq 0) ("and their results carry every field too (missing: " + (@($missing) -join ', ') + ")") Reset-PkAbortState diff --git a/ParallelKit.ps1 b/ParallelKit.ps1 index 0401054..ac9ab66 100644 --- a/ParallelKit.ps1 +++ b/ParallelKit.ps1 @@ -385,7 +385,7 @@ function Initialize-ParallelKit { $handlerOk = Register-PkAbortHandler $jobOk = Initialize-PkChildProcessJob $poolOk = Initialize-PkThreadPool -MaxConcurrentProcesses $MaxConcurrentProcesses - return @{ AbortHandler = $handlerOk; ChildProcessJob = $jobOk; ThreadPool = $poolOk } + return [pscustomobject][ordered]@{ AbortHandler = $handlerOk; ChildProcessJob = $jobOk; ThreadPool = $poolOk } } function Set-PkRunActive { @@ -659,8 +659,8 @@ function Wait-PkProcess { } function Complete-PkProcess { - # Call once the process has exited. Returns - # @{ ExitCode; Stdout; Stderr; StreamTimedOut; StreamError }. A process that exited + # Call once the process has exited. Returns an object with + # ExitCode, Stdout, Stderr, StreamTimedOut, StreamError. A process that exited # can still have an open pipe (a grandchild inherited it), so the stream # read is bounded too. param( @@ -669,7 +669,7 @@ function Complete-PkProcess { [int]$StreamTimeoutMs = 15000, [bool]$Dispose = $false ) - $result = @{ ExitCode = $null; Stdout = $null; Stderr = $null; StreamTimedOut = $false; StreamError = $null } + $result = [ordered]@{ ExitCode = $null; Stdout = $null; Stderr = $null; StreamTimedOut = $false; StreamError = $null } # A job started by a custom starter may have no read tasks, and a read # can fail. Neither is worth an exception here - this runs inside # JobFinalize, where a throw stops the whole dispatcher. @@ -688,12 +688,12 @@ function Complete-PkProcess { if ($null -ne $Job['StderrTask'] -and $Job['StderrTask'].Status -eq $ranToCompletion) { $result.Stderr = $Job['StderrTask'].Result } $result.ExitCode = $Job['Proc'].ExitCode if ($Dispose) { $Job['Proc'].Dispose() } - return $result + return [pscustomobject]$result } function Invoke-PkProcessWithTimeout { # Run one process to completion, synchronously, with a hard time limit. - # Returns @{ ExitCode; Stdout; Stderr; StreamTimedOut; StreamError }; + # Returns Complete-PkProcess's object (ExitCode, Stdout, Stderr, StreamTimedOut, StreamError); # throws if the process had to be killed - because it overran, or # because the run was aborted/cancelled while it was going. param( @@ -763,7 +763,8 @@ function Invoke-PkProcessDispatch { # has a Group field of its own. # $MaxConcurrent is an int, or a SemaphoreSlim shared between several # dispatchers that must stay under one combined limit. - # Returns @{ GroupResults; Aborted; ForceKilledCount; JobStarterFailedCount }. + # Returns an object with GroupResults, Aborted, ForceKilledCount, JobStarterFailedCount; + # each group result has GroupInfo, Completed, Aborted. # Anything your callbacks output is discarded, except where a return value is listed above. param( [Parameter(Mandatory)] @@ -878,7 +879,7 @@ function Invoke-PkProcessDispatchCore { $isSharedBudget = ($MaxConcurrent -is [System.Threading.SemaphoreSlim]) $groupStates = [System.Collections.Generic.Dictionary[object,hashtable]]::new() - $groupResults = [System.Collections.Generic.List[hashtable]]::new() + $groupResults = [System.Collections.Generic.List[object]]::new() $activeGroupCount = 0 $providerExhausted = $false $heldGroups = [System.Collections.Generic.List[object]]::new() @@ -886,7 +887,7 @@ function Invoke-PkProcessDispatchCore { $pending = [System.Collections.Generic.Queue[hashtable]]::new() $running = [System.Collections.Generic.List[hashtable]]::new() - $dispatchResult = @{ + $dispatchResult = [ordered]@{ GroupResults = $groupResults Aborted = $false ForceKilledCount = 0 @@ -936,7 +937,7 @@ function Invoke-PkProcessDispatchCore { $null = & $GroupCompleteCallback $groupInfo $state.Completed = $true if ($null -ne $GroupReleaseCheck) { $heldGroups.Add($groupInfo) } else { $activeGroupCount-- } - $groupResults.Add(@{ GroupInfo = $groupInfo; Completed = $true; Aborted = $false }) + $groupResults.Add([pscustomobject][ordered]@{ GroupInfo = $groupInfo; Completed = $true; Aborted = $false }) } } @@ -990,7 +991,7 @@ function Invoke-PkProcessDispatchCore { $null = & $GroupCompleteCallback $state.GroupInfo $state.Completed = $true $activeGroupCount-- - $groupResults.Add(@{ GroupInfo = $state.GroupInfo; Completed = $true; Aborted = $false }) + $groupResults.Add([pscustomobject][ordered]@{ GroupInfo = $state.GroupInfo; Completed = $true; Aborted = $false }) } } else { Write-PkMessage ("ProcessDispatch: invariant violation - job's GroupKey is not registered (JobStarter failure path). This should be unreachable; the dispatch loop may now hang.") -Level Error @@ -1034,7 +1035,7 @@ function Invoke-PkProcessDispatchCore { $null = & $GroupCompleteCallback $state.GroupInfo $state.Completed = $true $activeGroupCount-- - $groupResults.Add(@{ GroupInfo = $state.GroupInfo; Completed = $true; Aborted = $false }) + $groupResults.Add([pscustomobject][ordered]@{ GroupInfo = $state.GroupInfo; Completed = $true; Aborted = $false }) } } else { Write-PkMessage ("ProcessDispatch: invariant violation - job's GroupKey is not registered (main poll pass). This should be unreachable; the dispatch loop may now hang.") -Level Error @@ -1101,7 +1102,7 @@ function Invoke-PkProcessDispatchCore { $null = & $GroupCompleteCallback $state.GroupInfo $state.Completed = $true $activeGroupCount-- - $groupResults.Add(@{ GroupInfo = $state.GroupInfo; Completed = $true; Aborted = $false }) + $groupResults.Add([pscustomobject][ordered]@{ GroupInfo = $state.GroupInfo; Completed = $true; Aborted = $false }) } } else { Write-PkMessage ("ProcessDispatch: invariant violation - job's GroupKey is not registered (graceful-wait loop). This should be unreachable.") -Level Error @@ -1154,7 +1155,7 @@ function Invoke-PkProcessDispatchCore { $null = & $GroupCompleteCallback $state.GroupInfo $state.Completed = $true $activeGroupCount-- - $groupResults.Add(@{ GroupInfo = $state.GroupInfo; Completed = $true; Aborted = $false }) + $groupResults.Add([pscustomobject][ordered]@{ GroupInfo = $state.GroupInfo; Completed = $true; Aborted = $false }) } } else { Write-PkMessage ("ProcessDispatch: invariant violation - job's GroupKey is not registered (final pass). This should be unreachable.") -Level Error @@ -1214,11 +1215,32 @@ function Invoke-PkProcessDispatchCore { # every other group its result. try { $null = & $GroupCompleteCallback $state.GroupInfo } catch { } $state.Completed = $true - $groupResults.Add(@{ GroupInfo = $state.GroupInfo; Completed = $false; Aborted = $true }) + $groupResults.Add([pscustomobject][ordered]@{ GroupInfo = $state.GroupInfo; Completed = $false; Aborted = $true }) } } - return $dispatchResult + return [pscustomobject]$dispatchResult +} + +function New-PkBatchResult { + # One Invoke-PkProcessBatch result, the same shape however the command + # ended: ran (-Completed is Complete-PkProcess's object), couldn't start + # (-StartError), or stopped by an abort (-Aborted). + param( + [hashtable]$Command, + $Completed = $null, + [string]$StartError = $null, + [switch]$Aborted, + [bool]$TimedOut = $false + ) + $r = [ordered]@{ Command = $Command; ExitCode = $null; Stdout = $null; Stderr = $null; StreamTimedOut = $false; StreamError = $null + StartError = $null; Aborted = [bool]$Aborted; TimedOut = $TimedOut } + if ($null -ne $Completed) { + $r.ExitCode = $Completed.ExitCode; $r.Stdout = $Completed.Stdout; $r.Stderr = $Completed.Stderr + $r.StreamTimedOut = $Completed.StreamTimedOut; $r.StreamError = $Completed.StreamError + } + if ($StartError) { $r.StartError = $StartError } + return [pscustomobject]$r } function Invoke-PkProcessBatch { @@ -1228,9 +1250,10 @@ function Invoke-PkProcessBatch { # (not for one that couldn't start or was stopped by an abort). # $TimeoutSeconds > 0 kills a program still running after that long; its # result has TimedOut = $true (0 = no limit). - # Returns @{ Results; Aborted; ForceKilledCount }; each result is - # @{ Command; ExitCode; Stdout; Stderr; StreamTimedOut; StreamError; StartError; Aborted; TimedOut }, - # every field always present - keep the three places that build one in sync. + # Returns an object with Results, Aborted, ForceKilledCount; each result + # is an object with Command, ExitCode, Stdout, Stderr, StreamTimedOut, + # StreamError, StartError, Aborted, TimedOut - every field always present, + # since all three places that build one go through New-PkBatchResult. # $Commands may be $null: a foreach that matches nothing yields $null, not # an empty array, and an empty batch should just return no results. param( @@ -1246,7 +1269,7 @@ function Invoke-PkProcessBatch { if ($TimeoutSeconds -lt 0) { throw "Invoke-PkProcessBatch: -TimeoutSeconds must be 0 (no limit) or more." } - $results = [System.Collections.Generic.List[hashtable]]::new() + $results = [System.Collections.Generic.List[object]]::new() $state = @{ Given = $false } $groupProvider = { @@ -1270,7 +1293,7 @@ function Invoke-PkProcessBatch { try { $started = Start-PkProcess -FilePath $job['FilePath'] -Arguments $job['Arguments'] -WorkingDirectory $job['WorkingDirectory'] } catch { - $results.Add(@{ Command = $job['Command']; ExitCode = $null; Stdout = $null; Stderr = $null; StreamTimedOut = $false; StreamError = $null; StartError = [string]$_; Aborted = $false; TimedOut = $false }) + $results.Add((New-PkBatchResult -Command $job['Command'] -StartError ([string]$_))) throw } $job['Proc'] = $started.Proc; $job['StdoutTask'] = $started.StdoutTask; $job['StderrTask'] = $started.StderrTask @@ -1280,8 +1303,7 @@ function Invoke-PkProcessBatch { $jobFinalize = { param($job, $pendingQueue) - $r = Complete-PkProcess -Job $job - $r.Command = $job['Command']; $r.StartError = $null; $r.Aborted = $false; $r.TimedOut = [bool]$job['TimedOut'] + $r = New-PkBatchResult -Command $job['Command'] -Completed (Complete-PkProcess -Job $job) -TimedOut ([bool]$job['TimedOut']) $results.Add($r) if ($null -ne $OnComplete) { $null = & $OnComplete $job['Command'] $r } return @{ Success = ($r.ExitCode -eq 0) } @@ -1289,7 +1311,7 @@ function Invoke-PkProcessBatch { $jobAbortCleanup = { param($job) - $results.Add(@{ Command = $job['Command']; ExitCode = $null; Stdout = $null; Stderr = $null; StreamTimedOut = $false; StreamError = $null; StartError = $null; Aborted = $true; TimedOut = $false }) + $results.Add((New-PkBatchResult -Command $job['Command'] -Aborted)) }.GetNewClosure() $dispatchParams = @{ @@ -1312,7 +1334,7 @@ function Invoke-PkProcessBatch { } $dispatch = Invoke-PkProcessDispatch @dispatchParams - return @{ Results = $results; Aborted = $dispatch.Aborted; ForceKilledCount = $dispatch.ForceKilledCount } + return [pscustomobject][ordered]@{ Results = $results; Aborted = $dispatch.Aborted; ForceKilledCount = $dispatch.ForceKilledCount } } # ============================================================================== @@ -1324,7 +1346,7 @@ function Get-PkWorkerSafeFunctionNames { return @('Write-PkMessage', 'Test-PkAbortSignal', 'Test-PkCancelRequested', 'Initialize-PkThreadPool', 'Stop-PkProcessTree', 'Start-PkProcess', 'Wait-PkProcess', 'Complete-PkProcess', 'Invoke-PkProcessWithTimeout', 'Invoke-PkProcessDispatch', 'Invoke-PkProcessDispatchCore', 'Invoke-PkProcessBatch', - 'Register-PkCancellationSource', 'Unregister-PkCancellationSource', 'ConvertTo-PkArgumentString') + 'Register-PkCancellationSource', 'Unregister-PkCancellationSource', 'New-PkBatchResult', 'ConvertTo-PkArgumentString') } function Receive-PkCompletedHandles { @@ -1431,7 +1453,7 @@ function Invoke-PkRunspaceJobs { # PollCallback { } main thread, every loop pass # JobCrashedHandler { param($errorRecord) } main thread, when a worker throws # - # Returns @{ ForceStoppedCount; CrashedCount; Aborted }. + # Returns an object with ForceStoppedCount, CrashedCount, Aborted. param( [Parameter(Mandatory)] [scriptblock]$WorkerScript, @@ -1476,8 +1498,8 @@ function Invoke-PkRunspaceJobs { $JobParams = $normalizedJobs } - $outcome = @{ ForceStoppedCount = 0; CrashedCount = 0; Aborted = $false } - if ($null -ne $JobParams -and $JobParams.Count -eq 0) { return $outcome } + $outcome = [ordered]@{ ForceStoppedCount = 0; CrashedCount = 0; Aborted = $false } + if ($null -ne $JobParams -and $JobParams.Count -eq 0) { return [pscustomobject]$outcome } if ($CheckWorkerSafety) { $safetyFindings = Test-PkWorkerSafety -WorkerScript $WorkerScript -SharedFunctionNames $SharedFunctionNames @@ -1674,7 +1696,7 @@ function Invoke-PkRunspaceJobs { if ($registeredCallerCts) { Unregister-PkCancellationSource -Cts $Cts } } - return $outcome + return [pscustomobject]$outcome } # ============================================================================== @@ -1770,7 +1792,7 @@ function Get-PkScriptBlockJobMessage { function Test-PkWorkerSafety { # Static check of a worker script and the functions shared with it. # Returns a list of findings (empty = clean); each is - # @{ Rule; Where; Line; Text }. Put "# pk:ignore" on a line to accept it. + # an object with Rule, Where, Line, Text. Put "# pk:ignore" on a line to accept it. # # MissingFunction the worker calls one of your functions that isn't in -SharedFunctionNames # ScriptVariable a shared function (or a nested scriptblock or function in the worker) uses $script: @@ -1786,7 +1808,7 @@ function Test-PkWorkerSafety { [string[]]$SharedFunctionNames = @() ) - $findings = [System.Collections.Generic.List[hashtable]]::new() + $findings = [System.Collections.Generic.List[object]]::new() $kitNames = [System.Collections.Generic.HashSet[string]]::new([string[]]@(Get-PkWorkerSafeFunctionNames), [System.StringComparer]::OrdinalIgnoreCase) $listed = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($n in $kitNames) { $null = $listed.Add($n) } @@ -1799,7 +1821,7 @@ function Test-PkWorkerSafety { if ($kitNames.Contains($n)) { continue } $fn = Get-Item ("Function:\" + $n) -ErrorAction SilentlyContinue if ($null -eq $fn) { - $findings.Add(@{ Rule = 'MissingFunction'; Where = '-SharedFunctionNames'; Line = 0; Text = ("-SharedFunctionNames lists '" + $n + "', which is not a defined function") }) + $findings.Add([pscustomobject][ordered]@{ Rule = 'MissingFunction'; Where = '-SharedFunctionNames'; Line = 0; Text = ("-SharedFunctionNames lists '" + $n + "', which is not a defined function") }) continue } $targets.Add(@{ Where = ("function " + $n); Ast = $fn.ScriptBlock.Ast; IsWorkerBody = $false }) @@ -1816,10 +1838,10 @@ function Test-PkWorkerSafety { if (-not $kindCache.ContainsKey($cmdName)) { $kindCache[$cmdName] = Get-PkCommandKind -Name $cmdName } $kind = $kindCache[$cmdName] if ($kind -eq 'Function' -and -not $listed.Contains($cmdName)) { - $findings.Add(@{ Rule = 'MissingFunction'; Where = $t.Where; Line = $c.Extent.StartLineNumber + $findings.Add([pscustomobject][ordered]@{ Rule = 'MissingFunction'; Where = $t.Where; Line = $c.Extent.StartLineNumber Text = ($t.Where + " (line " + $c.Extent.StartLineNumber + ") calls '" + $cmdName + "', which is not in -SharedFunctionNames") }) } elseif ($kind -eq 'UnloadableModuleFunction' -and -not $listed.Contains($cmdName)) { - $findings.Add(@{ Rule = 'MissingFunction'; Where = $t.Where; Line = $c.Extent.StartLineNumber + $findings.Add([pscustomobject][ordered]@{ Rule = 'MissingFunction'; Where = $t.Where; Line = $c.Extent.StartLineNumber Text = ($t.Where + " (line " + $c.Extent.StartLineNumber + ") calls '" + $cmdName + "', from a module a worker can't load by name (imported by path?) - put the module on `$env:PSModulePath, or list the function in -SharedFunctionNames") }) } } @@ -1841,7 +1863,7 @@ function Test-PkWorkerSafety { foreach ($ref in $scopeAst.FindAll({ param($node) $node -is [System.Management.Automation.Language.VariableExpressionAst] -and $node.VariablePath.IsScript }, $true)) { if (-not $seenRefs.Add($ref.Extent.StartOffset)) { continue } if ($ref.Extent.StartScriptPosition.Line -match '#\s*pk:ignore') { continue } - $findings.Add(@{ Rule = 'ScriptVariable'; Where = $t.Where; Line = $ref.Extent.StartLineNumber + $findings.Add([pscustomobject][ordered]@{ Rule = 'ScriptVariable'; Where = $t.Where; Line = $ref.Extent.StartLineNumber Text = ($t.Where + " (line " + $ref.Extent.StartLineNumber + ") uses `$" + $ref.VariablePath.UserPath + " - it can silently read as empty in a worker") }) } } @@ -1849,7 +1871,7 @@ function Test-PkWorkerSafety { foreach ($cv in $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.ConvertExpressionAst] }, $true)) { if ($cv.Type.TypeName.Name -notmatch '^(System\.)?(Func|Action|Predicate|Comparison|Converter)(\[|`|$)') { continue } if ($cv.Extent.StartScriptPosition.Line -match '#\s*pk:ignore') { continue } - $findings.Add(@{ Rule = 'DelegateCast'; Where = $t.Where; Line = $cv.Extent.StartLineNumber + $findings.Add([pscustomobject][ordered]@{ Rule = 'DelegateCast'; Where = $t.Where; Line = $cv.Extent.StartLineNumber Text = ($t.Where + " (line " + $cv.Extent.StartLineNumber + ") casts a scriptblock to the delegate [" + $cv.Type.TypeName.Name + "] - this can hang or crash a worker") }) } @@ -1892,7 +1914,7 @@ function Test-PkWorkerSafety { } if ($null -eq $passed) { continue } if ($call.Extent.StartScriptPosition.Line -match '#\s*pk:ignore') { continue } - $findings.Add(@{ Rule = 'DelegateCast'; Where = $t.Where; Line = $call.Extent.StartLineNumber + $findings.Add([pscustomobject][ordered]@{ Rule = 'DelegateCast'; Where = $t.Where; Line = $call.Extent.StartLineNumber Text = ($t.Where + " (line " + $call.Extent.StartLineNumber + ") passes " + $passed + " to ." + $memberName + "() - .NET turns it into a delegate, which can hang or crash a worker (add '# pk:ignore' if this method really takes a scriptblock)") }) } } diff --git a/README.md b/README.md index 59ee3cb..7993362 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ It works whether or not your script uses `Set-StrictMode`. Dot-source it; don't `Import-Module` it. As a module it couldn't see the functions you list in `-SharedFunctionNames`. -`Initialize-ParallelKit` is optional. It registers the Ctrl+C handler, sets up child-process cleanup and sizes the thread pool up front; the engines set up what they need on first use either way. It returns `@{ AbortHandler; ChildProcessJob; ThreadPool }`, each `$true` if that part worked (a failed abort handler or child-process cleanup also prints a warning). +`Initialize-ParallelKit` is optional. It registers the Ctrl+C handler, sets up child-process cleanup and sizes the thread pool up front; the engines set up what they need on first use either way. It returns an object with `AbortHandler`, `ChildProcessJob` and `ThreadPool`, each `$true` if that part worked (a failed abort handler or child-process cleanup also prints a warning). Each public function has a comment block at the top of its definition in `ParallelKit.ps1` listing its parameters and what it returns. `Get-Help` doesn't show these; read the file. @@ -119,7 +119,7 @@ Long parameter lists in these examples use splatting (`@batchParams`) instead of Each command is a hashtable with `FilePath`, `Arguments` and optionally `WorkingDirectory`, plus any keys of your own. `Arguments` is passed to the program as one string, exactly as written. `ConvertTo-PkArgumentString @(...)` builds that string from a list, quoting each argument so the program gets exactly that list back, spaces, quotes and trailing backslashes included. It follows the rules most Windows programs use; `cmd.exe /c` parses its own way. Programs run hidden, with their output captured. -`Invoke-PkProcessBatch` returns `@{ Results; Aborted; ForceKilledCount }`. Each result is `@{ Command; ExitCode; Stdout; Stderr; StreamTimedOut; StreamError; StartError; Aborted; TimedOut }`, with every field always present: +`Invoke-PkProcessBatch` returns an object with `Results`, `Aborted` and `ForceKilledCount`. Each result is an object with `Command`, `ExitCode`, `Stdout`, `Stderr`, `StreamTimedOut`, `StreamError`, `StartError`, `Aborted` and `TimedOut`, every field always present, so `$batch.Results | Format-Table` reads well: - `Command` is your original command hashtable, so extra keys like `Source` above come back with it. - `ExitCode` is whatever the program returned. The kit doesn't treat a non-zero code as a failure; that's for you to decide. @@ -175,7 +175,7 @@ Each worker runs in its own runspace: a separate PowerShell session inside the s - `-PollCallback { ... }` runs on the main thread on every loop pass, for progress display and the like. - `-JobCrashedHandler { param($err) ... }` is told when a worker throws. That job's result handler isn't called. - If `-ResultHandler`, `-PollCallback` or `-NextJobProvider` throws, the run stops: workers still going are told to stop and the exception comes out of the call. -- Returns `@{ ForceStoppedCount; CrashedCount; Aborted }`. +- Returns an object with `ForceStoppedCount`, `CrashedCount` and `Aborted`. ## The two rules for worker code @@ -229,7 +229,7 @@ All of these callbacks run on whichever thread called the dispatcher: the main t - `-AbortPollCallback { param($runningJobs) }` runs on every loop pass, with the jobs currently running. - `-CancelToken` stops the dispatcher when cancelled. An abort stops it either way, without cancelling your token. - An exception from one of your callbacks stops the dispatcher: running processes are killed and the exception is rethrown. -- Returns `@{ GroupResults; Aborted; ForceKilledCount; JobStarterFailedCount }`. `GroupResults` holds one `@{ GroupInfo; Completed; Aborted }` per group, where `GroupInfo` is the group value. +- Returns an object with `GroupResults`, `Aborted`, `ForceKilledCount` and `JobStarterFailedCount`. `GroupResults` holds one object with `GroupInfo`, `Completed` and `Aborted` per group, where `GroupInfo` is the group value. Closures: build every callback that uses your local variables with `{ ... }.GetNewClosure()`. Without it, a variable name that happens to match one inside the kit silently reads the kit's value instead of yours. And keep any state a callback changes in a hashtable (`$state = @{ Count = 0 }`, then `$state.Count++`). A plain variable assigned inside a closure doesn't stick between calls. @@ -237,7 +237,7 @@ Closures: build every callback that uses your local variables with `{ ... }.GetN | Function | Use | |---|---| -| `Invoke-PkProcessWithTimeout -FilePath -Arguments -TimeoutMs` | Run one program and wait, with a hard time limit (default 60s). Returns `@{ ExitCode; Stdout; Stderr; StreamTimedOut; StreamError }`. If it overruns, or the run is aborted, the program is killed and this **throws**; add `-IgnoreAbort` for a cleanup step that must finish anyway. | +| `Invoke-PkProcessWithTimeout -FilePath -Arguments -TimeoutMs` | Run one program and wait, with a hard time limit (default 60s). Returns an object with `ExitCode`, `Stdout`, `Stderr`, `StreamTimedOut` and `StreamError`. If it overruns, or the run is aborted, the program is killed and this **throws**; add `-IgnoreAbort` for a cleanup step that must finish anyway. | | `ConvertTo-PkArgumentString @(args)` | Join a list of arguments into one correctly quoted command line, for any `-Arguments` / `Arguments` value. | | `Start-PkProcess -FilePath -Arguments` | Start one hidden, with output captured in the background. Returns `@{ Proc; StdoutTask; StderrTask; Stopwatch }`; throws if it can't start. | | `Wait-PkProcess -Process -TimeoutMs` | Wait for exit with a timeout that can't overrun. Returns `$true` if it exited. |