diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index c1859d6..e4cc5a4 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -1813,6 +1813,87 @@ foreach ($s in $scenarios) { } Write-Host "" +# ============================================================================== +Write-Host "=== W: relative paths and the current location ===" -ForegroundColor Cyan +# Programs start in the caller's current location, which cd/Set-Location +# change but the process's working directory doesn't follow. +Reset-Harness +$here = Join-Path $env:TEMP ("pk-harness-wd-" + $PID) +$null = New-Item -ItemType Directory -Force -Path (Join-Path $here 'sub') +[System.IO.File]::WriteAllText((Join-Path $here 'pk-wd.cmd'), "@echo pk-wd ran in %CD%`r`n") +$gone = Join-Path $env:TEMP ("pk-harness-wd-gone-" + $PID) +$null = New-Item -ItemType Directory -Force -Path $gone +function Get-HarnessCd { + # Where a program started by each entry point runs, via "cmd /c cd". + $wd = @{} + $wd.Batch = (Invoke-PkProcessBatch -Commands @(@{ FilePath = $cmdExe; Arguments = '/c cd' })).Results[0].Stdout.Trim() + $wd.WithTimeout = (Invoke-PkProcessWithTimeout -FilePath $cmdExe -Arguments '/c cd').Stdout.Trim() + $started = Start-PkProcess -FilePath $cmdExe -Arguments '/c cd' + $null = Wait-PkProcess -Process $started['Proc'] -TimeoutMs 10000 + $wd.StartProcess = (Complete-PkProcess -Job $started -Dispose $true).Stdout.Trim() + $out = @{ Text = $null } + $feed = @{ Given = $false } + $null = Invoke-PkProcessDispatch -MaxConcurrent 1 ` + -GroupProvider { if ($feed.Given) { return $null }; $feed.Given = $true; return 'g' }.GetNewClosure() ` + -JobBuilder { param($g) return @(@{ FilePath = $cmdExe; Arguments = '/c cd' }) }.GetNewClosure() ` + -JobFinalize { param($job, $pending) $out.Text = (Complete-PkProcess -Job $job).Stdout.Trim() }.GetNewClosure() + $wd.Dispatch = $out.Text + return $wd +} +Push-Location -LiteralPath $here +# %TEMP% can be an 8.3 short path (C:\Users\RUNNER~1\... on GitHub's +# runners); compare against the folder as PowerShell reports it. +$here = (Get-Location).ProviderPath +try { + Test-Assert ([Environment]::CurrentDirectory -ne $here) ("precondition: the process's working directory isn't the test folder (" + [Environment]::CurrentDirectory + ")") + $wd = Get-HarnessCd + $wrong = @($wd.Keys | Where-Object { $wd[$_] -ine $here }) + Test-Assert ($wrong.Count -eq 0) ("every entry point starts programs in the current location by default (wrong: " + (@($wrong | ForEach-Object { $_ + '=' + $wd[$_] }) -join ', ') + ")") + + $relText = $null + try { $relText = (Invoke-PkProcessWithTimeout -FilePath '.\pk-wd.cmd').Stdout.Trim() } catch { $relText = [string]$_ } + $missingText = '' + try { $null = Invoke-PkProcessWithTimeout -FilePath '.\no-such-tool.exe' } catch { $missingText = [string]$_ } + Test-Assert ($missingText -like ("*'.\no-such-tool.exe' (" + (Join-Path $here 'no-such-tool.exe') + ")*")) ("a program that can't be started is named with the full path that was tried (" + $missingText + ")") + $relBatch = (Invoke-PkProcessBatch -Commands @(@{ FilePath = '.\pk-wd.cmd' })).Results[0] + Test-Assert ($relText -ieq ("pk-wd ran in " + $here) -and $null -eq $relBatch.StartError -and $relBatch.Stdout.Trim() -ieq ("pk-wd ran in " + $here)) ("a FilePath with a folder in it (.\pk-wd.cmd) is found relative to the current location (" + $relText + ")") + + $subText = $null + try { $subText = (Invoke-PkProcessWithTimeout -FilePath $cmdExe -Arguments '/c cd' -WorkingDirectory 'sub').Stdout.Trim() } catch { $subText = [string]$_ } + Test-Assert ($subText -ieq (Join-Path $here 'sub')) ("a relative WorkingDirectory is relative to the current location (" + $subText + ")") + + $null = New-PSDrive -Name PkHarnessWd -PSProvider FileSystem -Root $here -Scope Script + try { + Push-Location -LiteralPath 'PkHarnessWd:\' + try { $onDrive = (Invoke-PkProcessWithTimeout -FilePath $cmdExe -Arguments '/c cd').Stdout.Trim() } finally { Pop-Location } + $driveText = $null + try { $driveText = (Invoke-PkProcessWithTimeout -FilePath 'PkHarnessWd:\pk-wd.cmd' -WorkingDirectory 'PkHarnessWd:\sub').Stdout.Trim() } catch { $driveText = [string]$_ } + } finally { Remove-PSDrive -Name PkHarnessWd -Scope Script } + Test-Assert ($onDrive -ieq $here) ("on a drive made with New-PSDrive, the program runs in the real folder behind it (" + $onDrive + ")") + Test-Assert ($driveText -ieq ("pk-wd ran in " + (Join-Path $here 'sub'))) ("a FilePath and WorkingDirectory on a New-PSDrive drive are translated to the real folders (" + $driveText + ")") + $homeText = $null + try { $homeText = (Invoke-PkProcessWithTimeout -FilePath $cmdExe -Arguments '/c cd' -WorkingDirectory '~').Stdout.Trim() } catch { $homeText = [string]$_ } + Test-Assert ($homeText -ieq (Resolve-Path -LiteralPath '~').ProviderPath) ("a WorkingDirectory of ~ is your home folder (" + $homeText + ")") + + Push-Location -LiteralPath 'HKLM:\' + try { $fromReg = (Invoke-PkProcessWithTimeout -FilePath $cmdExe -Arguments '/c cd').Stdout.Trim() } finally { Pop-Location } + Test-Assert ($fromReg -ieq $here) ("from a registry location, the program runs in the last file-system location (" + $fromReg + ")") + + Push-Location -LiteralPath $gone + try { + [System.IO.Directory]::Delete($gone, $true) # Remove-Item refuses to delete the current location + $fromGone = Invoke-PkProcessWithTimeout -FilePath $cmdExe -Arguments '/c cd' + Test-Assert ($fromGone.ExitCode -eq 0 -and $fromGone.Stdout.Trim().TrimEnd('\') -ieq [Environment]::CurrentDirectory.TrimEnd('\')) ("if the current folder has been deleted, the program runs in the process's working directory instead (" + $fromGone.Stdout.Trim() + ")") + } finally { Pop-Location } + + $explicit = Invoke-PkProcessWithTimeout -FilePath $cmdExe -Arguments '/c cd' -WorkingDirectory $env:SystemRoot + Test-Assert ($explicit.Stdout.Trim() -ieq $env:SystemRoot) "an explicit WorkingDirectory still wins over the current location" +} finally { + Pop-Location + Remove-Item -LiteralPath $here, $gone -Recurse -Force -ErrorAction SilentlyContinue +} +Write-Host "" + Set-PkMessageHandler -Handler $null Reset-PkAbortState diff --git a/ParallelKit.ps1 b/ParallelKit.ps1 index c7365fd..b3ac6c6 100644 --- a/ParallelKit.ps1 +++ b/ParallelKit.ps1 @@ -756,6 +756,23 @@ function Resolve-PkEncoding { } } +function Resolve-PkFileSystemPath { + # A path the way PowerShell reads it, turned into one Windows can use: + # relative to the current location, or on a drive like Data:\ or ~. + # Anything that isn't a file-system path to PowerShell (relative while + # the current location is HKLM:, say) is taken relative to $Here, the + # last file-system location. + param([string]$Path, [string]$Here) + if ([System.IO.Path]::IsPathRooted($Path)) { return $Path } + try { + $provider = $null + $drive = $null + $resolved = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path, [ref]$provider, [ref]$drive) + if ($provider.Name -eq 'FileSystem') { return $resolved } + } catch { } # not a path PowerShell can read (an unknown drive, say): the folder below is the best guess + return [System.IO.Path]::Combine($Here, $Path) +} + function Start-PkProcess { # .SYNOPSIS # Starts one program hidden, with its output captured in the background. @@ -765,12 +782,16 @@ function Start-PkProcess { # Pass what this returns to Wait-PkProcess and Complete-PkProcess. Throws if the # program can't be started. # .PARAMETER FilePath - # The program to run. + # The program to run. A path with a folder in it is read as PowerShell reads + # paths: .\tool.exe and bin\tool.exe are relative to your current location, and + # drives like Data:\ or ~ work. A bare name (tool.exe) is found the way Windows + # finds programs, mostly on PATH. # .PARAMETER Arguments # Its command line as one string (see ConvertTo-PkArgumentString). # .PARAMETER WorkingDirectory - # Where to run it. Default: the PowerShell process's working directory, which - # isn't always your current location. + # Where to run it. Default: your current location. Read as PowerShell reads + # paths, so a relative path is relative to your current location, and drives + # like Data:\ or ~ work. # .PARAMETER OutputEncoding # How to decode its output: a name like 'utf-8', a code page number, or a # [System.Text.Encoding]. Default: the console's code page. @@ -801,15 +822,36 @@ function Start-PkProcess { $psi.RedirectStandardOutput = $true $psi.RedirectStandardError = $true $psi.CreateNoWindow = $true - if ($WorkingDirectory) { $psi.WorkingDirectory = $WorkingDirectory } + + # Relative paths mean your current location, as everywhere else in + # PowerShell, not the process's working directory (which cd and + # Set-Location never change). From a location that isn't on disk (HKLM:, + # say) that's the last file-system one. If that folder is gone, fall back + # to the process's working directory, as before. + $here = $ExecutionContext.SessionState.Path.CurrentFileSystemLocation.ProviderPath + if (-not [System.IO.Directory]::Exists($here)) { $here = $null } # In a try/catch on purpose: a bare .NET call that throws does not # reliably stop a PowerShell script, and a $null process left behind - # would be polled forever. + # would be polled forever. The path handling is in here too: it throws + # on a path with characters Windows doesn't allow. try { + if ($WorkingDirectory) { + if ($null -ne $here) { $WorkingDirectory = Resolve-PkFileSystemPath -Path $WorkingDirectory -Here $here } + $psi.WorkingDirectory = $WorkingDirectory + } elseif ($null -ne $here) { + $psi.WorkingDirectory = $here + } + # Only a path with a folder in it: a bare name is left to Windows' + # own search, which looks on PATH. + if ($null -ne $here -and ($FilePath.Contains('\') -or $FilePath.Contains('/'))) { + $psi.FileName = Resolve-PkFileSystemPath -Path $FilePath -Here $here + } $proc = [System.Diagnostics.Process]::Start($psi) } catch { - throw ("Start-PkProcess: could not start '" + $FilePath + "': " + $_) + $tried = "" + if ($psi.FileName -ne $FilePath) { $tried = " (" + $psi.FileName + ")" } + throw ("Start-PkProcess: could not start '" + $FilePath + "'" + $tried + ": " + $_) } $null = [PkChildProcessJob]::Assign($proc) return @{ @@ -965,7 +1007,7 @@ function Invoke-PkProcessWithTimeout { # .PARAMETER StreamTimeoutMs # How long to wait for the output after the program exits (default 15000). # .PARAMETER WorkingDirectory - # Where to run it, as for Start-PkProcess. + # Where to run it, as for Start-PkProcess. Default: your current location. # .PARAMETER OutputEncoding # How to decode its output, as for Start-PkProcess. # .PARAMETER IgnoreAbort @@ -1685,7 +1727,7 @@ function Get-PkWorkerSafeFunctionNames { return @('Write-PkMessage', 'Test-PkAbortSignal', 'Test-PkCancelRequested', 'Initialize-PkThreadPool', 'Stop-PkProcessTree', 'Stop-PkJobProcesses', 'Resolve-PkEncoding', 'Start-PkProcess', 'Wait-PkProcess', 'Complete-PkProcess', 'Invoke-PkProcessWithTimeout', 'Invoke-PkProcessDispatch', 'Invoke-PkProcessDispatchCore', 'Invoke-PkProcessBatch', - 'Register-PkCancellationSource', 'Unregister-PkCancellationSource', 'New-PkBatchResult', 'ConvertTo-PkArgumentString', 'Get-PkForceStopReason') + 'Register-PkCancellationSource', 'Unregister-PkCancellationSource', 'New-PkBatchResult', 'ConvertTo-PkArgumentString', 'Get-PkForceStopReason', 'Resolve-PkFileSystemPath') } function Receive-PkCompletedHandles { diff --git a/README.md b/README.md index 5a90e1c..0726f97 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ Long parameter lists in these examples use splatting (`@batchParams`) instead of 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. +Programs run in your current location (`Get-Location`) unless a command says otherwise, and a relative `WorkingDirectory`, or a `FilePath` with a folder in it like `.\tools\cjxl.exe`, is relative to that location too. Drives PowerShell knows, like `~` or one made with `New-PSDrive`, work in both. A bare name like `cjxl.exe` is found the way Windows finds programs, mostly on `PATH`. A `.bat` or `.cmd` program can't run in a network-share (`\\server\share`) folder: `cmd.exe` falls back to `C:\Windows` there, so give such commands a local `WorkingDirectory`. + `Invoke-PkProcessBatch` returns an object with `Results`, `Aborted` and `ForceKilledCount`. Each result is an object with `Command`, `ExitCode`, `Stdout`, `Stderr`, `StreamTimedOut`, `StreamError`, `StartError`, `Aborted` and `TimedOut`, every field always present, so `$batch.Results | Format-Table` reads well: - `Command` is your original command hashtable, so extra keys like `Source` above come back with it.