diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index 6c2672d..0654f08 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -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" diff --git a/ParallelKit.ps1 b/ParallelKit.ps1 index cd09647..1e769ca 100644 --- a/ParallelKit.ps1 +++ b/ParallelKit.ps1 @@ -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 @@ -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 @@ -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. @@ -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 { } @@ -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 { } @@ -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 { } @@ -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') diff --git a/README.md b/README.md index 1363722..3ab0001 100644 --- a/README.md +++ b/README.md @@ -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?**