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
26 changes: 26 additions & 0 deletions ParallelKit.Harness.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,32 @@ $j = Start-PkProcess -FilePath $cmdExe -Arguments '/c exit 0'
$null = Wait-PkProcess -Process $j.Proc -TimeoutMs 5000
Test-Assert ([PkChildProcessJob]::Assign($j.Proc) -or $j.Proc.HasExited) "started processes are tied to the job object"
$j.Proc.Dispose()

# ConvertTo-PkArgumentString: exact output for the tricky cases, then a
# round trip through a real program that prints each argument it received.
Test-Assert ((ConvertTo-PkArgumentString @('plain', 'two words', '')) -eq 'plain "two words" ""') "arguments with spaces are quoted, empty ones become `"`""
Test-Assert ((ConvertTo-PkArgumentString @('C:\dir with space\')) -eq '"C:\dir with space\\"') "a trailing backslash is doubled inside quotes"
Test-Assert ((ConvertTo-PkArgumentString @('say "hi"', 'a\"b')) -eq '"say \"hi\"" "a\\\"b"') "quotes are escaped, and backslashes before them doubled"
Test-Assert ((ConvertTo-PkArgumentString @('C:\no\spaces\')) -eq 'C:\no\spaces\') "an argument that needs no quotes is left alone"
# Built with the .NET Framework's csc.exe (on every Windows machine) rather
# than Add-Type, which can't build an .exe under PowerShell 7.
$argvExe = Join-Path $env:TEMP ("pk-argv-" + [guid]::NewGuid().ToString("N") + ".exe")
$argvSrc = [System.IO.Path]::ChangeExtension($argvExe, '.cs')
[System.IO.File]::WriteAllText($argvSrc, 'public static class PkArgv { public static void Main(string[] args) { foreach (string a in args) { System.Console.WriteLine("[" + a + "]"); } } }')
$cscExe = Join-Path $env:SystemRoot 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'
$csc = $null
try { $csc = Invoke-PkProcessWithTimeout -FilePath $cscExe -Arguments (ConvertTo-PkArgumentString @('/nologo', ('/out:' + $argvExe), $argvSrc)) } catch { Write-Host " csc.exe failed to run: $_" }
[System.IO.File]::Delete($argvSrc)
$compiled = ($null -ne $csc -and $csc.ExitCode -eq 0 -and [System.IO.File]::Exists($argvExe))
Test-Assert $compiled "the argument-printing test program compiles"
if ($compiled) {
$tricky = @('plain', 'two words', '', 'C:\dir with space\', 'say "hi"', 'a\"b', 'tab here', 'x\\y', '\\server\share\')
$r = Invoke-PkProcessWithTimeout -FilePath $argvExe -Arguments (ConvertTo-PkArgumentString $tricky)
$received = @(([string]$r.Stdout).Split([string[]]@("`r`n"), [System.StringSplitOptions]::RemoveEmptyEntries))
$expected = @($tricky | ForEach-Object { "[" + $_ + "]" })
Test-Assert (($received -join '|') -eq ($expected -join '|')) ("a program receives exactly the arguments given (got " + ($received -join ' ') + ")")
try { [System.IO.File]::Delete($argvExe) } catch { } # temp file only; best effort
}
Write-Host ""

# ==============================================================================
Expand Down
46 changes: 45 additions & 1 deletion ParallelKit.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,50 @@ function Invoke-PkRun {
# External processes (all raw .NET - safe inside workers)
# ==============================================================================

function ConvertTo-PkArgumentString {
# Joins arguments into one command line, quoting each so a program sees
# exactly that list: an argument with a space, tab, newline or quote is
# wrapped in quotes, its quotes become \", and backslashes are doubled
# where they come before a quote. These are the C runtime's rules, which
# most Windows programs use (cmd.exe's own parsing is different).
# .NET Framework's ProcessStartInfo has no ArgumentList, hence this.
# ConvertTo-PkArgumentString @($in, '-o', $out) -> "C:\My Files\a.png" -o C:\out\a.jxl
param(
[Parameter(Mandatory, Position = 0)]
[AllowEmptyCollection()]
[AllowEmptyString()]
[string[]]$Arguments
)
$parts = [System.Collections.Generic.List[string]]::new()
foreach ($arg in $Arguments) {
if ($null -eq $arg) { $arg = '' }
if ($arg.Length -gt 0 -and $arg.IndexOfAny([char[]]@(' ', "`t", "`n", "`v", '"')) -lt 0) {
$parts.Add($arg)
continue
}
$sb = [System.Text.StringBuilder]::new()
$null = $sb.Append('"')
$backslashes = 0
foreach ($ch in $arg.ToCharArray()) {
if ($ch -eq '\') { $backslashes++; continue }
if ($ch -eq '"') {
# Each backslash before a quote is doubled, then the quote escaped.
$null = $sb.Append('\', 2 * $backslashes + 1)
$null = $sb.Append('"')
} else {
if ($backslashes -gt 0) { $null = $sb.Append('\', $backslashes) }
$null = $sb.Append($ch)
}
$backslashes = 0
}
# Trailing backslashes are doubled so they don't escape the closing quote.
if ($backslashes -gt 0) { $null = $sb.Append('\', 2 * $backslashes) }
$null = $sb.Append('"')
$parts.Add($sb.ToString())
}
return ($parts -join ' ')
}

function Start-PkProcess {
# Starts a hidden child process with stdout/stderr redirected and being
# read asynchronously (so a chatty process can't block on a full pipe).
Expand Down Expand Up @@ -1224,7 +1268,7 @@ function Get-PkWorkerSafeFunctionNames {
return @('Write-PkMessage', 'Test-PkAbortSignal', 'Test-PkCancelRequested', 'Initialize-PkThreadPool',
'Start-PkProcess', 'Wait-PkProcess', 'Complete-PkProcess', 'Invoke-PkProcessWithTimeout',
'Invoke-PkProcessDispatch', 'Invoke-PkProcessDispatchCore', 'Invoke-PkProcessBatch',
'Register-PkCancellationSource', 'Unregister-PkCancellationSource')
'Register-PkCancellationSource', 'Unregister-PkCancellationSource', 'ConvertTo-PkArgumentString')
}

function Receive-PkCompletedHandles {
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Most uses are this: a list of command-line programs, run a few at a time, where
. "$PSScriptRoot\ParallelKit.ps1"

$commands = foreach ($f in Get-ChildItem *.png) {
@{ FilePath = 'cjxl.exe'; Arguments = ('"{0}" "{0}.jxl"' -f $f.FullName) }
@{ FilePath = 'cjxl.exe'; Arguments = (ConvertTo-PkArgumentString @($f.FullName, "$($f.FullName).jxl")) }
}
$batch = Invoke-PkRun { Invoke-PkProcessBatch -Commands $commands -MaxConcurrent 6 }

Expand Down Expand Up @@ -102,7 +102,7 @@ Each public function has a comment block at the top of its definition in `Parall
```powershell
# Any command-line tool works. cjxl is a JPEG XL image encoder.
$commands = foreach ($f in Get-ChildItem *.png) {
@{ FilePath = 'cjxl.exe'; Arguments = ('"{0}" "{0}.jxl"' -f $f.FullName); Source = $f }
@{ FilePath = 'cjxl.exe'; Arguments = (ConvertTo-PkArgumentString @($f.FullName, "$($f.FullName).jxl")); Source = $f }
}
$batchParams = @{
Commands = $commands
Expand All @@ -117,7 +117,7 @@ $batch = Invoke-PkRun { Invoke-PkProcessBatch @batchParams }

Long parameter lists in these examples use splatting (`@batchParams`) instead of trailing backticks: it's easier to read, and a stray space after a backtick can't break it.

Each command is a hashtable with `FilePath`, `Arguments` and optionally `WorkingDirectory`, plus any keys of your own. `Arguments` is passed to the program as one string, exactly as written, so put quotes around paths that may contain spaces (as above). Programs run hidden, with their output captured.
Each command is a hashtable with `FilePath`, `Arguments` and optionally `WorkingDirectory`, plus any keys of your own. `Arguments` is passed to the program as one string, exactly as written. `ConvertTo-PkArgumentString @(...)` builds that string from a list, quoting each argument so the program gets exactly that list back, spaces, quotes and trailing backslashes included. It follows the rules most Windows programs use; `cmd.exe /c` parses its own way. Programs run hidden, with their output captured.

`Invoke-PkProcessBatch` returns `@{ Results; Aborted; ForceKilledCount }`. Each result is `@{ Command; ExitCode; Stdout; Stderr; StreamTimedOut; StreamError; StartError; Aborted }`, with every field always present:

Expand Down Expand Up @@ -235,6 +235,7 @@ Closures: build every callback that uses your local variables with `{ ... }.GetN
| Function | Use |
|---|---|
| `Invoke-PkProcessWithTimeout -FilePath -Arguments -TimeoutMs` | Run one program and wait, with a hard time limit (default 60s). Returns `@{ ExitCode; Stdout; Stderr; StreamTimedOut; StreamError }`. If it overruns, or the run is aborted, the program is killed and this **throws**; add `-IgnoreAbort` for a cleanup step that must finish anyway. |
| `ConvertTo-PkArgumentString @(args)` | Join a list of arguments into one correctly quoted command line, for any `-Arguments` / `Arguments` value. |
| `Start-PkProcess -FilePath -Arguments` | Start one hidden, with output captured in the background. Returns `@{ Proc; StdoutTask; StderrTask; Stopwatch }`; throws if it can't start. |
| `Wait-PkProcess -Process -TimeoutMs` | Wait for exit with a timeout that can't overrun. Returns `$true` if it exited. |
| `Complete-PkProcess -Job` | Collect exit code and output from a finished one (`-Job` is what `Start-PkProcess` returned). Same result as `Invoke-PkProcessWithTimeout`. |
Expand Down
Loading