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
81 changes: 81 additions & 0 deletions ParallelKit.Harness.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 50 additions & 8 deletions ParallelKit.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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 @{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading