diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index 6c2672d..f8d7570 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -795,6 +795,36 @@ $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" +# 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. $clmScript = Join-Path $env:TEMP ("pk-clm-" + [guid]::NewGuid().ToString("N") + ".ps1") diff --git a/ParallelKit.ps1 b/ParallelKit.ps1 index cd09647..15da280 100644 --- a/ParallelKit.ps1 +++ b/ParallelKit.ps1 @@ -1960,3 +1960,25 @@ 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. +# 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 diff --git a/README.md b/README.md index 1363722..cc47f1b 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,8 @@ 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`. +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). 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. @@ -236,7 +238,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