From 8512e7841f83a19576a1e15a44f43d6f6d51ffde Mon Sep 17 00:00:00 2001 From: Hal <22532613+HalRiveria@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:19:47 -0400 Subject: [PATCH 1/2] Add ConvertTo-PkArgumentString for quoting program arguments .NET Framework's ProcessStartInfo has no ArgumentList, so every caller hand-quoted paths into one Arguments string, which breaks on embedded quotes and trailing backslashes. ConvertTo-PkArgumentString joins a list of arguments using the C runtime's quoting rules. It's available inside workers too. README examples use it. Co-Authored-By: Claude Opus 5.5 --- ParallelKit.Harness.ps1 | 21 +++++++++++++++++++ ParallelKit.ps1 | 46 ++++++++++++++++++++++++++++++++++++++++- README.md | 7 ++++--- 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index 39bfebe..431b4fe 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -180,6 +180,27 @@ $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' +$null = Invoke-PkProcessWithTimeout -FilePath $cscExe -Arguments (ConvertTo-PkArgumentString @('/nologo', ('/out:' + $argvExe), $argvSrc)) +[System.IO.File]::Delete($argvSrc) +$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`. | From 9cc19891818c9fbdbae0105c4ef3ec7146d47b05 Mon Sep 17 00:00:00 2001 From: Hal <22532613+HalRiveria@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:40:36 -0400 Subject: [PATCH 2/2] Harness: report a failed compile of the argument test program as a FAIL The round-trip test ignored csc.exe's result, so a failed compile ended the harness with a 'could not start' error instead of a [FAIL] line. Co-Authored-By: Claude Opus 5.5 --- ParallelKit.Harness.ps1 | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index 431b4fe..b819e97 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -193,14 +193,19 @@ $argvExe = Join-Path $env:TEMP ("pk-argv-" + [guid]::NewGuid().ToString("N") + " $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' -$null = Invoke-PkProcessWithTimeout -FilePath $cscExe -Arguments (ConvertTo-PkArgumentString @('/nologo', ('/out:' + $argvExe), $argvSrc)) +$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) -$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 +$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 "" # ==============================================================================