From 2959c47d9717508410f4af1aedb7837aae8fbd35 Mon Sep 17 00:00:00 2001 From: Hal <22532613+HalRiveria@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:51:24 -0500 Subject: [PATCH] Fix drive-less paths and drain error reports; version 2.2 A WorkingDirectory or FilePath like \tools or C:tools depends on a current drive and folder. The kit passed those straight to Windows, which used the process's rather than PowerShell's current location: the start failed, or the program ran in the wrong place. Only complete paths now go straight through; the rest are resolved the way PowerShell reads them. A non-terminating error in a worker that finished during an abort's drain wasn't reported, so a partial result looked fine. It now gets the same "Runspace error:" message as in the main loop. Three helpers with no callers are removed, along with a no-op assignment. Co-Authored-By: Claude Opus 5.5 --- ParallelKit.Harness.ps1 | 39 ++++++++++++++++++++++++++++++++++ ParallelKit.ps1 | 46 +++++++++++++++++------------------------ README.md | 2 +- 3 files changed, 59 insertions(+), 28 deletions(-) diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index bea2f94..c9fbd90 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -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 diff --git a/ParallelKit.ps1 b/ParallelKit.ps1 index c6d7ee9..0b8b58c 100644 --- a/ParallelKit.ps1 +++ b/ParallelKit.ps1 @@ -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. # @@ -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. @@ -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 @@ -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 @@ -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) } @@ -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) @@ -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 { @@ -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 } } @@ -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' } diff --git a/README.md b/README.md index 4e3a996..54cce31 100644 --- a/README.md +++ b/README.md @@ -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"