From f05705467586e3413a255644881e73933a39ae79 Mon Sep 17 00:00:00 2001 From: Hal <22532613+HalRiveria@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:10:12 -0400 Subject: [PATCH 1/3] Make the kit's functions global so closures can call them When a script dot-sourced the kit and was run from a prompt (.\script.ps1 or & script), the kit's functions existed only in that script's scope. A .GetNewClosure() block can only see global functions, so Invoke-PkProcessBatch - whose internal callbacks are closures - failed every command ("Start-PkProcess is not recognized"), and so did any caller closure that called a kit function. Under powershell -File it worked, which is how every test ran, so this went unnoticed since v1.0. The kit now also defines each of its functions globally when loaded. README: notes the global functions, and that a closure can't call the script's own functions when run from a prompt unless they're global. Co-Authored-By: Claude Opus 5.5 --- ParallelKit.Harness.ps1 | 19 +++++++++++++++++++ ParallelKit.ps1 | 15 +++++++++++++++ README.md | 4 ++-- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index 6c2672d..06bd7af 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -795,6 +795,25 @@ $probeThreads = 0 if ($probe.Stdout -match 'threads=(\d+)') { $probeThreads = [int]$Matches[1] } Test-Assert ($probeThreads -ge 128) ("and raises the thread pool by itself (minimum " + $probeThreads + ")") +# A script run the way a prompt runs it (& script, or .\script.ps1) holds +# the kit's functions in its own scope, where a .GetNewClosure() block can't +# see them. The kit makes them global too, so the batch (whose callbacks are +# closures) and a caller's closure that calls a kit function both work. +# Needs a fresh process, started with -Command rather than -File. +$promptScript = Join-Path $env:TEMP ("pk-prompt-" + [guid]::NewGuid().ToString("N") + ".ps1") +[System.IO.File]::WriteAllText($promptScript, @" +. '$kitPath' +`$b = Invoke-PkProcessBatch -Commands @(@{ FilePath = '$cmdExe'; Arguments = '/c exit 7' }) +Write-Output ('batch=' + `$b.Results.Count + ',' + `$b.Results[0].ExitCode) +`$tag = 'ok' +`$closure = Invoke-PkRun { Test-PkCancelRequested | Out-Null; 'closure=' + `$tag }.GetNewClosure() +Write-Output `$closure +"@) +$prompt = Invoke-PkProcessWithTimeout -FilePath $selfExe -Arguments (ConvertTo-PkArgumentString @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ("& '" + $promptScript + "'"))) -TimeoutMs 60000 +[System.IO.File]::Delete($promptScript) +Test-Assert ($prompt.Stdout -match 'batch=1,7') ("a batch works in a script run from a prompt (" + ([string]$prompt.Stdout + [string]$prompt.Stderr).Trim() + ")") +Test-Assert ($prompt.Stdout -match 'closure=ok') "and so does a .GetNewClosure() block that calls a kit function" + # Loading the kit in Constrained Language Mode fails with a message that # says so. Needs a fresh process: the mode can't be set back. $clmScript = Join-Path $env:TEMP ("pk-clm-" + [guid]::NewGuid().ToString("N") + ".ps1") diff --git a/ParallelKit.ps1 b/ParallelKit.ps1 index cd09647..bb95b19 100644 --- a/ParallelKit.ps1 +++ b/ParallelKit.ps1 @@ -1960,3 +1960,18 @@ function Test-PkWorkerSafety { return ,$findings } + +# ============================================================================== +# Make the kit's functions global +# ============================================================================== + +# Dot-sourced inside a script run from a prompt (.\script.ps1 or & script), +# the functions above exist only in that script's scope. A .GetNewClosure() +# block - the batch's own callbacks, or yours - can only see global +# functions, so it couldn't find them. Defining each one globally too means +# kit functions work from any closure, however the script was started. +foreach ($pkFunction in $MyInvocation.MyCommand.ScriptBlock.Ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false)) { + $pkLocal = Get-Item -LiteralPath ("Function:\" + $pkFunction.Name) + $null = Set-Item -Path ("Function:\global:" + $pkFunction.Name) -Value $pkLocal.ScriptBlock +} +Remove-Variable -Name pkFunction, pkLocal -ErrorAction SilentlyContinue diff --git a/README.md b/README.md index 1363722..0d0e3a0 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ If you downloaded the file, Windows marks it as coming from the internet and you 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`. +Dot-source it; don't `Import-Module` it. As a module it couldn't see the functions you list in `-SharedFunctionNames`. Its functions are also defined globally, so they stay available for the rest of that PowerShell session, and a `.GetNewClosure()` block can call them however your script was started. `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). @@ -236,7 +236,7 @@ All of these callbacks run on whichever thread called the dispatcher: the main t - An exception from one of your callbacks stops the dispatcher: running processes are killed and the exception is rethrown. - 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. +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. A closure can't see functions your script defines when the script is run from a prompt (`.\script.ps1`); the kit's own functions always work, but define any of yours that a closure calls as `function global:Get-Thing { ... }`. ## Single processes From facd5ee9d42efeeaafa0f9160ed2fe6520234f76 Mon Sep 17 00:00:00 2001 From: Hal <22532613+HalRiveria@users.noreply.github.com> Date: Wed, 28 Oct 2026 06:10:32 -0400 Subject: [PATCH 2/3] README: put the global-functions note in its own paragraph Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0d0e3a0..cc47f1b 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,9 @@ If you downloaded the file, Windows marks it as coming from the internet and you 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`. Its functions are also defined globally, so they stay available for the rest of that PowerShell session, and a `.GetNewClosure()` block can call them however your script was started. +Dot-source it; don't `Import-Module` it. As a module it couldn't see the functions you list in `-SharedFunctionNames`. + +The kit's functions are also defined globally, so they stay available for the rest of that PowerShell session, and a `.GetNewClosure()` block can call them however your script was started. `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). From de47b2b385fb319266cd0e75d2c11a96379c6f8d Mon Sep 17 00:00:00 2001 From: Hal <22532613+HalRiveria@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:25:33 -0400 Subject: [PATCH 3/3] Make only the kit's own functions global, from a child scope The export walked every function in the file the kit's code sits in, so a script that pasted the kit in had its own functions made global, and one defined after the kit caused a 'Cannot find path' error at load (fatal under strict mode). The loop also ran in the caller's scope, overwriting and removing two of its variables. It now only exports Pk-named functions and Initialize-ParallelKit, from a child scope. Co-Authored-By: Claude Opus 5.5 --- ParallelKit.Harness.ps1 | 11 +++++++++++ ParallelKit.ps1 | 17 ++++++++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index 06bd7af..f8d7570 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -813,6 +813,17 @@ $prompt = Invoke-PkProcessWithTimeout -FilePath $selfExe -Arguments (ConvertTo-P [System.IO.File]::Delete($promptScript) Test-Assert ($prompt.Stdout -match 'batch=1,7') ("a batch works in a script run from a prompt (" + ([string]$prompt.Stdout + [string]$prompt.Stderr).Trim() + ")") Test-Assert ($prompt.Stdout -match 'closure=ok') "and so does a .GetNewClosure() block that calls a kit function" +# Pasted into a larger script under strict mode, only the kit's functions +# are made global: the script's own (here one defined after the kit, and +# one before) are left alone, and loading raises no errors. +$embedScript = Join-Path $env:TEMP ("pk-embed-" + [guid]::NewGuid().ToString("N") + ".ps1") +[System.IO.File]::WriteAllText($embedScript, "Set-StrictMode -Version Latest`r`n`$ErrorActionPreference = 'Stop'`r`nfunction Get-HarnessBefore { 1 }`r`n`$fnAst = 'mine'`r`n" + + [System.IO.File]::ReadAllText($kitPath) + + "`r`nfunction Get-HarnessAfter { 2 }`r`nWrite-Output ('kitGlobal=' + [bool](Get-ChildItem Function: | Where-Object { `$_.Name -eq 'Start-PkProcess' }))`r`nWrite-Output ('caller var=' + `$fnAst)`r`n") +$embed = Invoke-PkProcessWithTimeout -FilePath $selfExe -Arguments (ConvertTo-PkArgumentString @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ("& '" + $embedScript + "'; 'before global=' + [bool](Get-Command Get-HarnessBefore -ErrorAction SilentlyContinue); 'after global=' + [bool](Get-Command Get-HarnessAfter -ErrorAction SilentlyContinue); 'kit global=' + [bool](Get-Command Start-PkProcess -ErrorAction SilentlyContinue)"))) -TimeoutMs 60000 +[System.IO.File]::Delete($embedScript) +$embedOut = ([string]$embed.Stdout + [string]$embed.Stderr).Trim() +Test-Assert ($embedOut -match 'kit global=True' -and $embedOut -match 'before global=False' -and $embedOut -match 'after global=False' -and $embedOut -match 'caller var=mine' -and [string]::IsNullOrWhiteSpace([string]$embed.Stderr)) ("pasted into a larger script, only the kit's functions are made global, without errors or touching the script's variables (" + ($embedOut -replace '\s+', ' ') + ")") # Loading the kit in Constrained Language Mode fails with a message that # says so. Needs a fresh process: the mode can't be set back. diff --git a/ParallelKit.ps1 b/ParallelKit.ps1 index bb95b19..15da280 100644 --- a/ParallelKit.ps1 +++ b/ParallelKit.ps1 @@ -1970,8 +1970,15 @@ function Test-PkWorkerSafety { # block - the batch's own callbacks, or yours - can only see global # functions, so it couldn't find them. Defining each one globally too means # kit functions work from any closure, however the script was started. -foreach ($pkFunction in $MyInvocation.MyCommand.ScriptBlock.Ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false)) { - $pkLocal = Get-Item -LiteralPath ("Function:\" + $pkFunction.Name) - $null = Set-Item -Path ("Function:\global:" + $pkFunction.Name) -Value $pkLocal.ScriptBlock -} -Remove-Variable -Name pkFunction, pkLocal -ErrorAction SilentlyContinue +# Only the kit's own functions (Pk-named, plus Initialize-ParallelKit): the +# kit may be pasted into a larger script whose other functions are none of +# its business. Run in a child scope so the loop's variables don't touch +# the caller's. +& { + param($kitAst) + foreach ($fnAst in $kitAst.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $false)) { + if ($fnAst.Name -notlike '*-Pk*' -and $fnAst.Name -ne 'Initialize-ParallelKit') { continue } + $fn = Get-Item -LiteralPath ("Function:\" + $fnAst.Name) -ErrorAction SilentlyContinue + if ($null -ne $fn) { $null = Set-Item -Path ("Function:\global:" + $fnAst.Name) -Value $fn.ScriptBlock } + } +} $MyInvocation.MyCommand.ScriptBlock.Ast