From 34f31da8e380ba95ca2e1ca0ab93f2f69199d9b2 Mon Sep 17 00:00:00 2001 From: Hal <22532613+HalRiveria@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:14:57 -0400 Subject: [PATCH 1/2] Start each worker job in the caller's location, in a fresh scope Worker runspaces started in the PowerShell process's working directory, not the caller's current location, so a relative path in a worker cmdlet (Get-Content .\data.txt) looked in the wrong folder. Runspaces are also reused from job to job, and the worker ran at the runspace's global scope. So a job that changed folder moved every later job on that runspace, and a variable a job set, $ErrorActionPreference included, carried over to the next one. A worker that set $ErrorActionPreference = 'Stop' made later jobs on that runspace crash on errors they would otherwise only have reported. Each job now moves to the caller's current location first (the real folder behind a New-PSDrive drive), then runs the worker in a scope of its own with the job's values. Both happen in one script: a Set-Location statement added ahead of the worker hangs the pipeline in a runspace pool, on 5.1 and 7. Error line numbers still match the worker's own code. Co-Authored-By: Claude Opus 5.5 --- ParallelKit.Harness.ps1 | 51 +++++++++++++++++++++++++++++++++++++++++ ParallelKit.ps1 | 27 +++++++++++++++++++--- README.md | 1 + 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index e4cc5a4..e188046 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -1894,6 +1894,57 @@ try { } Write-Host "" +# ============================================================================== +Write-Host "=== X: each worker job starts clean ===" -ForegroundColor Cyan +# Worker runspaces are reused from job to job. Each job should start in the +# caller's location, without anything an earlier job left behind. +Reset-Harness +$workerDir = Join-Path $env:TEMP ("pk-harness-worker-" + $PID) +$null = New-Item -ItemType Directory -Force -Path $workerDir +[System.IO.File]::WriteAllText((Join-Path $workerDir 'data.txt'), 'worker-data') +$cleanWorker = { + param([int]$Id) + $start = [pscustomobject]@{ + Id = $Id + Location = (Get-Location).ProviderPath + Leak = (Get-Variable -Name pkHarnessLeak -ValueOnly -ErrorAction SilentlyContinue) + EAP = [string]$ErrorActionPreference + Data = (Get-Content -LiteralPath '.\data.txt' -ErrorAction SilentlyContinue) + } + # Everything that used to carry over to the next job on the same runspace. + Set-Location -LiteralPath $env:SystemRoot + $pkHarnessLeak = "from job " + $Id + $ErrorActionPreference = 'Stop' + $start +} +Push-Location -LiteralPath $workerDir +$workerDir = (Get-Location).ProviderPath # the long form; %TEMP% can be an 8.3 path +try { + $seen = [System.Collections.Generic.List[object]]::new() + $null = Invoke-PkRunspaceJobs -MaxParallel 1 -JobParams (New-JobList -Jobs @(@{ Id = 1 }, @{ Id = 2 }, @{ Id = 3 })) ` + -WorkerScript $cleanWorker -ResultHandler { param($r) $seen.Add($r) }.GetNewClosure() + $badLocation = @($seen | Where-Object { $_.Location -ine $workerDir } | ForEach-Object { "job " + $_.Id + "=" + $_.Location }) + Test-Assert ($seen.Count -eq 3 -and $badLocation.Count -eq 0) ("each worker job starts in the caller's location, even after an earlier job changed folder (wrong: " + ($badLocation -join ', ') + ")") + Test-Assert (@($seen | Where-Object { $_.Data -ne 'worker-data' }).Count -eq 0) "a relative path in a worker cmdlet is relative to the caller's location" + $leaked = @($seen | Where-Object { $null -ne $_.Leak -or $_.EAP -ne 'Continue' } | ForEach-Object { "job " + $_.Id + ": leak=" + $_.Leak + " EAP=" + $_.EAP }) + Test-Assert ($leaked.Count -eq 0) ("variables a job sets, preference variables included, don't carry over to the next job (" + ($leaked -join '; ') + ")") + + $null = New-PSDrive -Name PkHarnessJobs -PSProvider FileSystem -Root $workerDir -Scope Script + try { + Push-Location -LiteralPath 'PkHarnessJobs:\' + try { + $onDrive = [System.Collections.Generic.List[object]]::new() + $null = Invoke-PkRunspaceJobs -MaxParallel 1 -JobParams (New-JobList -Jobs @(@{ Id = 1 })) ` + -WorkerScript $cleanWorker -ResultHandler { param($r) $onDrive.Add($r) }.GetNewClosure() + } finally { Pop-Location } + } finally { Remove-PSDrive -Name PkHarnessJobs -Scope Script } + Test-Assert ($onDrive.Count -eq 1 -and $onDrive[0].Location -ieq $workerDir) ("from a New-PSDrive drive, workers start in the real folder behind it (" + @($onDrive | ForEach-Object { $_.Location }) + ")") +} finally { + Pop-Location + Remove-Item -LiteralPath $workerDir -Recurse -Force -ErrorAction SilentlyContinue +} +Write-Host "" + Set-PkMessageHandler -Handler $null Reset-PkAbortState diff --git a/ParallelKit.ps1 b/ParallelKit.ps1 index b3ac6c6..3f8d1c9 100644 --- a/ParallelKit.ps1 +++ b/ParallelKit.ps1 @@ -1826,6 +1826,8 @@ function Invoke-PkRunspaceJobs { # Each job hashtable's keys become the worker's parameters. A worker runspace # starts empty apart from cmdlets, installed modules and the kit's own worker # functions, so name every function of yours it calls in -SharedFunctionNames. + # Each job starts in your current location, in a fresh scope: a folder change + # or a variable from an earlier job doesn't carry over. # The worker code is checked against the worker rules (see the README) # before anything starts, and each job's values before that job starts; # code or values that break them are refused. @@ -1983,6 +1985,20 @@ function Invoke-PkRunspaceJobs { $pool = [RunspaceFactory]::CreateRunspacePool(1, $MaxParallel, $iss, $Host) $pool.Open() + # Each job starts in the caller's current location, the folder + # Start-PkProcess defaults to as well. The real folder, not a drive + # name: a New-PSDrive drive doesn't exist in the worker. + $workerLocation = $ExecutionContext.SessionState.Path.CurrentFileSystemLocation.ProviderPath + # Every job runs this: move to that folder, then run the worker in a + # scope of its own with the job's values. Set per job because + # runspaces are reused: a job that changed folder would otherwise move + # the next one too. One script rather than a Set-Location statement + # ahead of the worker: a second statement in a pool runspace's + # pipeline never completes (seen on 5.1 and 7). The prefix shares the + # worker's first line, so error line numbers still match its code. + $jobScript = 'param([string]$PkJobLocation, [hashtable]$PkJobValues) if ($PkJobLocation) { Set-Location -LiteralPath $PkJobLocation }; & {' + + $WorkerScript.ToString() + "`n} @PkJobValues" + while ((-not $scanExhausted -or $handles.Count -gt 0) -and -not $Cts.Token.IsCancellationRequested) { while (-not $scanExhausted -and $handles.Count -lt $MaxParallel -and -not $Cts.Token.IsCancellationRequested) { @@ -2020,11 +2036,16 @@ function Invoke-PkRunspaceJobs { $ps = [PowerShell]::Create() $ps.RunspacePool = $pool - $null = $ps.AddScript($WorkerScript) - foreach ($key in $nextJob.Keys) { $null = $ps.AddParameter($key, $nextJob[$key]) } + $jobValues = @{} + foreach ($key in $nextJob.Keys) { $jobValues[$key] = $nextJob[$key] } if ($declaredParams.Contains('CancelToken') -and -not $nextJob.ContainsKey('CancelToken')) { - $null = $ps.AddParameter("CancelToken", $Cts.Token) + $jobValues['CancelToken'] = $Cts.Token } + $jobLocation = "" + if ([System.IO.Directory]::Exists($workerLocation)) { $jobLocation = $workerLocation } + # A local scope too, so variables a job sets (preference + # variables included) don't carry over to the next job. + $null = $ps.AddScript($jobScript, $true).AddParameter('PkJobLocation', $jobLocation).AddParameter('PkJobValues', $jobValues) $handles.Add(@{ PS = $ps; Handle = $ps.BeginInvoke() }) } diff --git a/README.md b/README.md index 0726f97..7ba7e2d 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ Each worker runs in its own runspace: a separate PowerShell session inside the s - If the worker declares a `$CancelToken` parameter, it gets the run's cancellation token. - `-JobParams` takes a list or array of job hashtables. A single hashtable works too, so a list that happens to hold one job is fine. - A worker runspace starts empty. Name every one of your functions it calls in `-SharedFunctionNames`. The kit's own worker functions are always there, and cmdlets and functions from installed modules load on their own. A module you imported by path isn't installed, so list its functions too (or put it on `$env:PSModulePath`). +- Each job starts in your current location (the real folder, if you're on a `New-PSDrive` drive), in a fresh scope. A folder change or a variable one job makes, preference variables like `$ErrorActionPreference` included, doesn't carry over to the next job. Relative paths in .NET calls such as `[System.IO.File]::ReadAllText('x.txt')` still go by the process's working directory, as they do outside workers, so give .NET full paths. - Whatever the worker returns is handed to `-ResultHandler` on the main thread, once per object returned. - `-NextJobProvider { ... }` can replace `-JobParams` to produce jobs lazily: it's called on the main thread for the next job hashtable, and returns `$null` when there are no more. - `-PollCallback { ... }` runs on the main thread on every loop pass, for progress display and the like. From 3cd5354b1b9223ffa776c4007a64d67e1300fcd2 Mon Sep 17 00:00:00 2001 From: Hal <22532613+HalRiveria@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:18:13 -0400 Subject: [PATCH 2/2] Don't report an error per job if the caller's folder disappears mid-run Co-Authored-By: Claude Opus 5.5 --- ParallelKit.ps1 | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/ParallelKit.ps1 b/ParallelKit.ps1 index 3f8d1c9..5d0b142 100644 --- a/ParallelKit.ps1 +++ b/ParallelKit.ps1 @@ -1993,10 +1993,12 @@ function Invoke-PkRunspaceJobs { # scope of its own with the job's values. Set per job because # runspaces are reused: a job that changed folder would otherwise move # the next one too. One script rather than a Set-Location statement - # ahead of the worker: a second statement in a pool runspace's - # pipeline never completes (seen on 5.1 and 7). The prefix shares the - # worker's first line, so error line numbers still match its code. - $jobScript = 'param([string]$PkJobLocation, [hashtable]$PkJobValues) if ($PkJobLocation) { Set-Location -LiteralPath $PkJobLocation }; & {' + + # ahead of the worker: with a second statement, a pool runspace's + # pipeline hadn't completed after 10 s in testing, on 5.1 and 7. The + # prefix shares the worker's first line, so error line numbers still + # match its code. If the folder is gone by the time a job starts, the + # job runs wherever its runspace already is, without an error per job. + $jobScript = 'param([string]$PkJobLocation, [hashtable]$PkJobValues) if ($PkJobLocation) { Set-Location -LiteralPath $PkJobLocation -ErrorAction SilentlyContinue }; & {' + $WorkerScript.ToString() + "`n} @PkJobValues" while ((-not $scanExhausted -or $handles.Count -gt 0) -and -not $Cts.Token.IsCancellationRequested) {