Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions ParallelKit.Harness.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2218,6 +2218,45 @@ try { $null = Invoke-PkProcessWithTimeout -FilePath $cmdExe -Arguments '/c cd' -
$injected = $global:pkHarnessInjected
Remove-Variable -Name pkHarnessInjected -Scope Global
Test-Assert (-not $injected) "a path with a curly quote stays a path when it's looked up in the caller's session"

# \tools and C:tools depend on a current drive and folder: PowerShell's, not
# the process's. (Only a real test when the process's folder is on another
# drive, which it is on GitHub's runners and usually elsewhere.)
Reset-Harness
$windows = $env:SystemRoot
$noDriveDir = $windows.Substring(2) + '\System32'
$expectDir = Join-Path $windows 'System32'
$incompleteResult = @{}
Push-Location -LiteralPath $windows
try {
foreach ($case in @(@{ Name = 'no drive'; Path = $noDriveDir }, @{ Name = 'drive with no folder'; Path = ($windows.Substring(0, 2) + 'System32') })) {
try { $incompleteResult[$case.Name] = (Invoke-PkProcessWithTimeout -FilePath $cmdExe -Arguments '/c cd' -WorkingDirectory $case.Path).Stdout.Trim() } catch { $incompleteResult[$case.Name] = [string]$_ }
}
Push-Location -LiteralPath 'HKLM:\'
try { $incompleteResult['from the registry'] = (Invoke-PkProcessWithTimeout -FilePath $cmdExe -Arguments '/c cd' -WorkingDirectory $noDriveDir).Stdout.Trim() } catch { $incompleteResult['from the registry'] = [string]$_ }
finally { Pop-Location }
$fileNoDrive = $null
try { $fileNoDrive = (Invoke-PkProcessWithTimeout -FilePath ($windows.Substring(2) + '\System32\cmd.exe') -Arguments '/c exit 5').ExitCode } catch { $fileNoDrive = [string]$_ }
} finally { Pop-Location }
$wrongDirs = @($incompleteResult.Keys | Where-Object { $incompleteResult[$_] -ine $expectDir } | ForEach-Object { $_ + '=' + $incompleteResult[$_] })
Test-Assert ($wrongDirs.Count -eq 0) ("a WorkingDirectory that depends on a current drive or folder follows PowerShell's location, not the process's (wrong: " + ($wrongDirs -join ', ') + ")")
Test-Assert ($fileNoDrive -eq 5) ("and so does a FilePath with no drive (" + $fileNoDrive + ")")

# A non-terminating error in a worker that finishes during an abort's drain
# is reported, the same as one that finishes in the main loop.
Reset-Harness
$poll = @{ Count = 0 }
$softOut = [System.Collections.Generic.List[object]]::new()
$softRun = Invoke-PkRunspaceJobs -MaxParallel 2 -JobParams (New-JobList -Jobs @(@{ Id = 1 }, @{ Id = 2 })) -WorkerScript {
param([int]$Id)
for ($i = 0; $i -lt 100; $i++) { if (Test-PkCancelRequested) { break }; [System.Threading.Thread]::Sleep(50) }
[System.Threading.Thread]::Sleep(500)
Write-Error ("soft drain " + $Id)
return $Id
} -PollCallback { $poll.Count++; if ($poll.Count -eq 3) { Request-PkAbort } }.GetNewClosure() -ResultHandler { param($r) $softOut.Add($r) }.GetNewClosure()
$softMessages = @($script:messages | Where-Object { $_.Level -eq 'Error' -and $_.Message -like 'Runspace error: soft drain*' }).Count
Test-Assert ($softRun.Aborted -and $softRun.CrashedCount -eq 0 -and $softOut.Count -eq 2 -and $softMessages -eq 2) ("a non-terminating error during an abort's drain is reported (messages " + $softMessages + ", results " + $softOut.Count + ")")
Reset-Harness
Write-Host ""

Set-PkMessageHandler -Handler $null
Expand Down
46 changes: 19 additions & 27 deletions ParallelKit.ps1
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# ParallelKit - parallel runspace jobs, bounded external-process dispatch, and
# graceful Ctrl+C/window-close handling for Windows PowerShell 5.1.
#
# Version 2.1 - https://github.com/HalRiveria/PowerShell-ParallelKit
# Version 2.2 - https://github.com/HalRiveria/PowerShell-ParallelKit
# Copyright (c) 2026 HalRiveria. MIT License: keep this notice with any copy;
# the full text is in LICENSE in the repository.
#
Expand Down Expand Up @@ -482,16 +482,6 @@ function Initialize-ParallelKit {
return [pscustomobject][ordered]@{ AbortHandler = $handlerOk; ChildProcessJob = $jobOk; ThreadPool = $poolOk }
}

function Set-PkRunActive {
param([Parameter(Mandatory)][bool]$Active)
[PkConsoleCtrl]::RunActive = $Active
}

function Set-PkConsoleQuickEdit {
param([Parameter(Mandatory)][bool]$Enabled)
return [PkConsoleQuickEdit]::SetEnabled($Enabled)
}

function Register-PkCancellationSource {
# .SYNOPSIS
# Adds your own CancellationTokenSource to the set an abort cancels.
Expand Down Expand Up @@ -622,13 +612,6 @@ function Complete-PkAbortDrain {
try { [PkConsoleCtrl]::DrainComplete.Set() } catch { } # nothing useful to do if the event itself is broken
}

function Test-PkOwnAbortException {
# True when an exception is just the fallout of our own abort.
param([Parameter(Mandatory)][System.Management.Automation.ErrorRecord]$ErrorRecord)
$exType = $ErrorRecord.Exception.GetType().FullName
return ((Test-PkAbortSignal) -and ($exType -like '*PipelineStoppedException*' -or $exType -like '*OperationCanceled*'))
}

function Invoke-PkRun {
# .SYNOPSIS
# Runs a block with graceful Ctrl+C: the kit's engines wind their work down
Expand Down Expand Up @@ -770,7 +753,9 @@ function Resolve-PkFileSystemPath {
# the current location is HKLM:, say) is taken relative to $Here, the
# last file-system location.
param([string]$Path, [string]$Here, [System.Management.Automation.SessionState]$CallerState)
if ([System.IO.Path]::IsPathRooted($Path)) { return $Path }
# Only a complete path goes straight through. \tools and C:tools depend on
# a current drive and folder, which are PowerShell's here, not the process's.
if ($Path -match '^([A-Za-z]:[\\/]|[\\/]{2})') { return $Path }
$lookupFailed = $false
try {
$provider = $null
Expand Down Expand Up @@ -798,6 +783,9 @@ function Resolve-PkFileSystemPath {
$hit = @($ExecutionContext.InvokeCommand.InvokeScript($CallerState, [scriptblock]::Create($text), @()))
if ($hit.Count -gt 0) { return [string]$hit[0] }
}
# \tools while the current location is HKLM:, say: the root of that last
# file-system location's drive, not the process's.
if ($Path -match '^[\\/]') { return [System.IO.Path]::Combine([System.IO.Path]::GetPathRoot($Here), $Path.TrimStart('\', '/')) }
# Not a path PowerShell can read (an unknown drive, say): this folder is the best guess.
return [System.IO.Path]::Combine($Here, $Path)
}
Expand Down Expand Up @@ -888,9 +876,9 @@ function Start-PkProcessCore {
} elseif ($null -ne $here) {
$psi.WorkingDirectory = $here
}
# Only a path with a folder in it: a bare name is left to Windows'
# own search, which looks on PATH.
if ($null -ne $here -and ($FilePath.Contains('\') -or $FilePath.Contains('/'))) {
# Only a path with a folder or drive in it: a bare name is left to
# Windows' own search, which looks on PATH.
if ($null -ne $here -and ($FilePath.Contains('\') -or $FilePath.Contains('/') -or $FilePath.Contains(':'))) {
$psi.FileName = Resolve-PkFileSystemPath -Path $FilePath -Here $here -CallerState $CallerState
}
$proc = [System.Diagnostics.Process]::Start($psi)
Expand Down Expand Up @@ -1907,6 +1895,10 @@ function Receive-PkCompletedHandles {
$pkWorkerOutput = $null
try {
$pkWorkerOutput = $h.PS.EndInvoke($h.Handle)
# Same report as Invoke-PkRunspaceJobs' main loop.
if ($h.PS.HadErrors) {
foreach ($e in $h.PS.Streams.Error) { Invoke-PkMessageHandler -Message ("Runspace error: " + $e.ToString()) -Level Error }
}
} catch {
Add-PkCrashedJob -PkOutcome $Outcome -PkJob $h['Job'] -PkError $_ -PkHandler $JobCrashedHandler -PkWhen " during abort drain"
} finally {
Expand Down Expand Up @@ -2241,6 +2233,7 @@ function Invoke-PkRunspaceJobs {
$pkWorkerOutput = $null
try {
$pkWorkerOutput = $h.PS.EndInvoke($h.Handle)
# Same report as Receive-PkCompletedHandles.
if ($h.PS.HadErrors) {
foreach ($e in $h.PS.Streams.Error) { Invoke-PkMessageHandler -Message ("Runspace error: " + $e.ToString()) -Level Error }
}
Expand Down Expand Up @@ -2372,16 +2365,15 @@ function Get-PkCommandKind {
# module on $env:PSModulePath, and the check runs on every
# Invoke-PkRunspaceJobs call.
param([System.Management.Automation.FunctionInfo]$Fn)
$fn = $Fn
if ($null -eq $fn) { return 'Other' }
if ([string]::IsNullOrEmpty($fn.ModuleName)) { return 'Function' }
if ($null -eq $Fn) { return 'Other' }
if ([string]::IsNullOrEmpty($Fn.ModuleName)) { return 'Function' }
# The kit's own: a worker gets only the worker-safe ones, so treat the
# rest like a function of yours (needs listing), as before the kit was a module.
if ($fn.ModuleName -eq 'ParallelKit') { return 'Function' }
if ($Fn.ModuleName -eq 'ParallelKit') { return 'Function' }
# A module function (Get-FileHash, say) autoloads in a worker like a
# cmdlet does - but only if the module can be found by name. One imported
# by path from elsewhere can't.
if ($null -ne (Get-Module -ListAvailable -Name $fn.ModuleName -ErrorAction SilentlyContinue | Select-Object -First 1)) { return 'Other' }
if ($null -ne (Get-Module -ListAvailable -Name $Fn.ModuleName -ErrorAction SilentlyContinue | Select-Object -First 1)) { return 'Other' }
return 'UnloadableModuleFunction'
}

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ What none of them do for you:

## Setup

Requires Windows, with Windows PowerShell 5.1 or PowerShell 7. Download `ParallelKit.ps1` from the [Releases page](https://github.com/HalRiveria/PowerShell-ParallelKit/releases) (this README describes version 2.1 and later), put it in your project, and dot-source it from your script:
Requires Windows, with Windows PowerShell 5.1 or PowerShell 7. Download `ParallelKit.ps1` from the [Releases page](https://github.com/HalRiveria/PowerShell-ParallelKit/releases) (this README describes version 2.2 and later), put it in your project, and dot-source it from your script:

```powershell
. "$PSScriptRoot\ParallelKit.ps1"
Expand Down
Loading