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
38 changes: 38 additions & 0 deletions ParallelKit.Harness.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -1233,6 +1233,44 @@ $j = Start-PkProcess -FilePath $cmdExe -Arguments '/c exit 0'
$null = Wait-PkProcess -Process $j.Proc -TimeoutMs 5000
Test-Assert (-not (Stop-PkProcessTree -Process $j.Proc)) "Stop-PkProcessTree reports `$false for a program that already exited"
$j.Proc.Dispose()

# Every other place the kit kills a program kills its children too: a
# Invoke-PkProcessWithTimeout overrun, and the dispatcher's force-stop
# after an abort. Each test's ping has its own count, so a surviving one
# can be found by its command line.
function Get-HarnessPingCount([int]$Count) {
return @(Get-CimInstance Win32_Process -Filter "Name = 'PING.EXE'" | Where-Object { $_.CommandLine -like ("*-n " + $Count + " 127.0.0.1*") }).Count
}
$nestedPing = { param($n) '/c ""' + $pingExe + '" -n ' + $n + ' 127.0.0.1"' }
try { $null = Invoke-PkProcessWithTimeout -FilePath $cmdExe -Arguments (& $nestedPing 41) -TimeoutMs 1500 } catch { } # the overrun throw is expected
Test-Assert ((Get-HarnessPingCount 41) -eq 0) "Invoke-PkProcessWithTimeout: an overrun kills the program's children too"
Reset-Harness
$abortTimer = [PowerShell]::Create()
$null = $abortTimer.AddScript({ [System.Threading.Thread]::Sleep(1500); [PkConsoleCtrl]::AbortRequested = $true })
$abortTimerHandle = $abortTimer.BeginInvoke()
$batch = Invoke-PkProcessBatch -AbortTimeoutSeconds 1 -Commands @(@{ FilePath = $cmdExe; Arguments = (& $nestedPing 43) })
$null = $abortTimer.EndInvoke($abortTimerHandle); $abortTimer.Dispose()
$several = @(45, 46, 47 | ForEach-Object { Start-PkProcess -FilePath $cmdExe -Arguments (& $nestedPing $_) })
Start-Sleep -Milliseconds 500
$killedSeveral = Stop-PkProcessTree -Process @($several | ForEach-Object { $_.Proc })
Test-Assert ($killedSeveral -and (Get-HarnessPingCount 45) -eq 0 -and (Get-HarnessPingCount 46) -eq 0 -and (Get-HarnessPingCount 47) -eq 0 -and @($several | Where-Object { -not $_.Proc.HasExited }).Count -eq 0) "Stop-PkProcessTree kills several programs and their children in one call"
foreach ($j in $several) { $j.Proc.Dispose() }
# A parent and its own child in the same call: both die, and the call says
# it killed them. (The case this guards - taskkill failing for one PID that
# exited a moment before - is a race this can't set up on demand.)
$parentJob = Start-PkProcess -FilePath $cmdExe -Arguments (& $nestedPing 48)
$childProc = $null
$waitChild = [System.Diagnostics.Stopwatch]::StartNew()
while ($null -eq $childProc -and $waitChild.Elapsed.TotalSeconds -lt 5) {
$childCim = @(Get-CimInstance Win32_Process -Filter ("ParentProcessId = " + $parentJob.Proc.Id) | Where-Object { $_.Name -eq 'PING.EXE' })
if ($childCim.Count -gt 0) { $childProc = [System.Diagnostics.Process]::GetProcessById([int]$childCim[0].ProcessId) } else { Start-Sleep -Milliseconds 100 }
}
$killedPair = $false
if ($null -ne $childProc) { $killedPair = Stop-PkProcessTree -Process @($parentJob.Proc, $childProc) }
Test-Assert ($null -ne $childProc -and $killedPair -and $parentJob.Proc.HasExited -and $childProc.HasExited) ("Stop-PkProcessTree kills a parent and its child passed together, and says so (found child: " + ($null -ne $childProc) + ", returned: " + $killedPair + ")")
$parentJob.Proc.Dispose(); if ($null -ne $childProc) { $childProc.Dispose() }
Test-Assert ($batch.Aborted -and $batch.ForceKilledCount -eq 1 -and (Get-HarnessPingCount 43) -eq 0) ("dispatch: the force-stop after an abort kills the program's children too (aborted=" + $batch.Aborted + ", force-killed=" + $batch.ForceKilledCount + ")")
Reset-PkAbortState
$threw = $false
try { $null = Invoke-PkProcessBatch -Commands @(@{ FilePath = $cmdExe; Arguments = '/c exit 0' }) -TimeoutSeconds -1 } catch { $threw = ($_.ToString() -like '*-TimeoutSeconds*') }
Test-Assert $threw "a negative -TimeoutSeconds is refused"
Expand Down
57 changes: 41 additions & 16 deletions ParallelKit.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -638,14 +638,22 @@ function Start-PkProcess {
}

function Stop-PkProcessTree {
# Kills a process and every process it started (taskkill /T /F), so
# none of them is left holding its output open. Falls back to killing
# just the process. Returns $true if this call killed it, $false if it
# had already exited on its own.
param([Parameter(Mandatory)][System.Diagnostics.Process]$Process)
if ($Process.HasExited) { return $false }
# Kills processes and every process they started (taskkill /T /F, one
# call for all of them), so none is left holding output open. Falls back
# to killing a process directly. With one process, returns $true if this
# call killed it, $false if it had already exited on its own; with
# several, $true if this call killed any of them.
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[System.Diagnostics.Process[]]$Process
)
$alive = [System.Collections.Generic.List[System.Diagnostics.Process]]::new()
foreach ($p in $Process) { if ($null -ne $p -and -not $p.HasExited) { $alive.Add($p) } }
if ($alive.Count -eq 0) { return $false }
$killed = $false
$psi = [System.Diagnostics.ProcessStartInfo]::new((Join-Path $env:SystemRoot 'System32\taskkill.exe'), ('/T /F /PID ' + $Process.Id))
$pidArgs = foreach ($p in $alive) { '/PID ' + $p.Id }
$psi = [System.Diagnostics.ProcessStartInfo]::new((Join-Path $env:SystemRoot 'System32\taskkill.exe'), ('/T /F ' + ($pidArgs -join ' ')))
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardOutput = $true
Expand All @@ -655,14 +663,31 @@ function Stop-PkProcessTree {
try {
if ($taskkill.WaitForExit(10000)) { $killed = ($taskkill.ExitCode -eq 0) }
} finally { $taskkill.Dispose() }
} catch { } # taskkill missing or blocked: the fallback below still kills the process itself
if (-not $killed -and -not $Process.HasExited) {
try { $Process.Kill(); $killed = $true } catch { } # it exited between the check and the kill
} catch { } # taskkill missing or blocked: the fallback below still kills each process itself
# With several PIDs taskkill reports failure if any one was already gone
# (it exited, or was a child in another listed tree) even when it killed
# the rest; count it as killing them if any listed process is now gone.
if (-not $killed -and $alive.Count -gt 1) {
foreach ($p in $alive) { if ($p.HasExited) { $killed = $true; break } }
}
if ($killed) { $null = $Process.WaitForExit(5000) }
foreach ($p in $alive) {
if (-not $p.HasExited) {
try { $p.Kill(); $killed = $true } catch { } # it exited between the check and the kill
}
}
if ($killed) { foreach ($p in $alive) { $null = $p.WaitForExit(5000) } }
return $killed
}

function Stop-PkJobProcesses {
# The dispatcher's force-stop: every running job's process tree in one
# Stop-PkProcessTree call, rather than one taskkill per job. A custom
# starter's .Proc may not be a Process; those are skipped.
param([System.Collections.Generic.List[hashtable]]$Jobs)
$procs = foreach ($job in $Jobs) { if ($job['Proc'] -is [System.Diagnostics.Process]) { $job['Proc'] } }
try { $null = Stop-PkProcessTree -Process @($procs) } catch { } # best effort; each job is still waited on and disposed after
}

function Wait-PkProcess {
# Waits on the raw process handle. Returns $true if it exited in time.
# (Process.WaitForExit(int) also waits on the redirected streams and can
Expand Down Expand Up @@ -748,7 +773,7 @@ function Invoke-PkProcessWithTimeout {
if (-not $exited -and -not $IgnoreAbort -and ((Test-PkAbortSignal) -or (Test-PkCancelRequested))) { $stopWhy = "was stopped because the run is being aborted"; break }
}
if (-not $exited) {
try { $job['Proc'].Kill() } catch { } # already-exited race - the throw below still reports why
try { $null = Stop-PkProcessTree -Process $job['Proc'] } catch { } # best effort - the throw below still reports why
# Let the two output reads finish (the kill closes their pipes)
# before the process is disposed under them. Bounded, and any
# read error is irrelevant - the stop reason is what gets reported.
Expand Down Expand Up @@ -1078,8 +1103,8 @@ function Invoke-PkProcessDispatchCore {
}
} catch {
# Best-effort teardown on the way out; the original error is rethrown.
Stop-PkJobProcesses -Jobs $running
foreach ($job in $running) {
try { if ($job['Proc'] -and -not $job['Proc'].HasExited) { $job['Proc'].Kill() } } catch { }
try { if ($job['Proc']) { $null = $job['Proc'].WaitForExit(5000) } } catch { }
if (-not $job['SlotReleased']) {
try { $null = & $JobAbortCleanup $job } catch { }
Expand Down Expand Up @@ -1198,8 +1223,8 @@ function Invoke-PkProcessDispatchCore {
if ($running.Count -gt 0) {
$dispatchResult.ForceKilledCount = $running.Count
Write-PkMessage ("ProcessDispatch: force-stopping " + $running.Count + " job(s) that did not finish within " + $AbortTimeoutSeconds + "s.") -Level Warn
Stop-PkJobProcesses -Jobs $running
foreach ($job in $running) {
try { if ($job['Proc'] -and -not $job['Proc'].HasExited) { $job['Proc'].Kill() } } catch { }
try { if ($job['Proc']) { $null = $job['Proc'].WaitForExit(5000) } } catch { }
try { $null = & $JobAbortCleanup $job } catch { }
try { if ($job['Proc']) { $job['Proc'].Dispose() } } catch { }
Expand All @@ -1208,8 +1233,8 @@ function Invoke-PkProcessDispatchCore {
}
}
} catch {
Stop-PkJobProcesses -Jobs $running
foreach ($job in $running) {
try { if ($job['Proc'] -and -not $job['Proc'].HasExited) { $job['Proc'].Kill() } } catch { }
try { if ($job['Proc']) { $null = $job['Proc'].WaitForExit(5000) } } catch { }
if (-not $job['SlotReleased']) {
try { $null = & $JobAbortCleanup $job } catch { }
Expand Down Expand Up @@ -1382,7 +1407,7 @@ function Invoke-PkProcessBatch {

function Get-PkWorkerSafeFunctionNames {
# Kit functions every worker runspace gets automatically.
return @('Write-PkMessage', 'Test-PkAbortSignal', 'Test-PkCancelRequested', 'Initialize-PkThreadPool', 'Stop-PkProcessTree', 'Resolve-PkEncoding',
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')
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ The ones in this README: the engines, the [single-process functions](#single-pro

**Does a program I start get my Ctrl+C too?**

No. Programs start without a window, in a hidden console of their own, so Ctrl+C in your window never reaches them. That's what lets them finish while the kit drains. When it does need to stop one, the kit kills it.
No. Programs start without a window, in a hidden console of their own, so Ctrl+C in your window never reaches them. That's what lets them finish while the kit drains. When it does need to stop one (a timeout, or an abort's force-stop), the kit kills it along with anything it started.

**Was this written by AI?**

Expand Down
Loading