diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index 39bfebe..b819e97 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -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 "" # ============================================================================== diff --git a/ParallelKit.ps1 b/ParallelKit.ps1 index b4aa176..8319880 100644 --- a/ParallelKit.ps1 +++ b/ParallelKit.ps1 @@ -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). @@ -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 { diff --git a/README.md b/README.md index 8452e2d..b8a82ce 100644 --- a/README.md +++ b/README.md @@ -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 } @@ -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 @@ -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: @@ -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`. |