From 43347fc032b3112d6bcb4a7b8130589bdd8c1d85 Mon Sep 17 00:00:00 2001 From: Hal <22532613+HalRiveria@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:03:39 -0400 Subject: [PATCH 1/2] Let a second Ctrl+C skip the abort drain The console handler parked on every Ctrl+C until the drain finished, and Windows delivers a process's console events one at a time. So a second Ctrl+C was held back for the whole drain (60 s by default). Under Invoke-PkRun it then arrived after the run had ended and stopped the script, losing a batch's result and everything after it. A window close after Ctrl+C was held back the same way, so -JobAbortCleanup never ran before Windows ended the process. Ctrl+C and Ctrl+Break no longer park. The first press starts the abort, the next one skips the drain (running jobs are force-stopped straight away and the script carries on), and a third is passed on so PowerShell stops the script. Presses less than a second apart count as one. Only a window close, logoff or shutdown still parks the handler, now until the run ends rather than just the runspace drain, so the rest of the run gets the few seconds Windows allows. Abort notices now say what started the abort (Ctrl+Break and Request-PkAbort are no longer announced as Ctrl+C) and how to skip the wait, and force-stop notices give the real reason instead of always "did not finish within Ns". Get-PkAbortTriggerLabel returns "Ctrl+Break" or "Request-PkAbort" for those cases. The compiled types change, so TypesVersion goes to 4. Co-Authored-By: Claude Opus 5.5 --- ParallelKit.Harness.ps1 | 109 ++++++++++++++++++++++++++-- ParallelKit.ps1 | 155 +++++++++++++++++++++++++++++----------- README.md | 4 +- 3 files changed, 220 insertions(+), 48 deletions(-) diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index fc6fb61..60fc555 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -148,8 +148,7 @@ Test-Assert (@($results | Where-Object { $_.Stopped }).Count -eq 2) "in-flight w Test-Assert (@($results | Where-Object { $_.TokenSeen }).Count -eq 2) "a declared `$CancelToken parameter receives the run's token" Test-Assert ($out.ForceStoppedCount -eq 0) "cooperative workers are not force-stopped" Test-Assert ($sw.Elapsed.TotalSeconds -lt 8) ("the run ends promptly after the abort (" + [int]$sw.Elapsed.TotalSeconds + "s)") -Test-Assert (@($script:messages | Where-Object { $_.Message -like '*Ctrl+C detected*' }).Count -eq 1) "the abort is announced exactly once" -Test-Assert ([PkConsoleCtrl]::DrainComplete.IsSet) "the native handler thread is released after the drain" +Test-Assert (@($script:messages | Where-Object { $_.Message -like '*Abort requested*' }).Count -eq 1) "the abort is announced exactly once" # A worker that ignores the cancel gets force-stopped after the drain timeout. Reset-Harness @@ -716,7 +715,7 @@ Invoke-PkRun -ScriptBlock { } }.GetNewClosure() Test-Assert ($own.Steps -eq 3) "your own loop inside Invoke-PkRun can stop on an abort by polling Test-PkAbortSignal" -Test-Assert (@($script:messages | Where-Object { $_.Message -like '*Ctrl+C detected*' }).Count -eq 1) "and the abort is announced once" +Test-Assert (@($script:messages | Where-Object { $_.Message -like '*Abort requested*' }).Count -eq 1) "and the abort is announced once" # QuickEdit is put back the way it was found. Reset-Harness @@ -1133,7 +1132,7 @@ $sw = [System.Diagnostics.Stopwatch]::StartNew() try { $null = Invoke-PkProcessWithTimeout -FilePath $pingExe -Arguments '-n 30 127.0.0.1' -TimeoutMs 25000 } catch { $threw = ($_.ToString() -like '*being aborted*') } $sw.Stop() Test-Assert ($threw -and $sw.Elapsed.TotalSeconds -lt 6) ("Invoke-PkProcessWithTimeout stops its program when the run is aborted (" + [math]::Round($sw.Elapsed.TotalSeconds, 1) + "s, time limit was 25s)") -Test-Assert (@($script:messages | Where-Object { $_.Message -like '*Ctrl+C detected*' }).Count -eq 1) "and the abort is announced" +Test-Assert (@($script:messages | Where-Object { $_.Message -like '*Abort requested*' }).Count -eq 1) "and the abort is announced" $null = $abortTimer.EndInvoke($abortTimerHandle); $abortTimer.Dispose() # A window close gets no drain, so its announcement mustn't say it waits. @@ -1392,6 +1391,108 @@ Test-Assert ($d.Aborted -and $called.Count -eq 1 -and $called.SawAbort) "an abor Reset-PkAbortState Write-Host "" +# ============================================================================== +Write-Host "=== U: pressing Ctrl+C again ===" -ForegroundColor Cyan +# [PkConsoleCtrl]::OnInterrupt is what the native handler calls for each +# Ctrl+C/Ctrl+Break press during a run, so calling it here is a key press +# minus Windows. Real console events are covered separately. + +# The press sequence: start the abort, skip the drain, then let it through. +Reset-Harness +[PkConsoleCtrl]::SkipDebounceMs = 0 +try { + $presses = @([PkConsoleCtrl]::OnInterrupt('Ctrl+C'), [PkConsoleCtrl]::OnInterrupt('Ctrl+C'), [PkConsoleCtrl]::OnInterrupt('Ctrl+C')) +} finally { + [PkConsoleCtrl]::SkipDebounceMs = 1000 +} +Test-Assert ($presses[0] -and $presses[1] -and -not $presses[2]) ("the 1st and 2nd presses are handled, the 3rd is passed on to PowerShell (" + ($presses -join ',') + ")") +Test-Assert ([PkConsoleCtrl]::AbortRequested -and [PkConsoleCtrl]::SkipDrain) "the 1st press starts the abort and the 2nd skips the drain" +Reset-PkAbortState +Test-Assert (-not [PkConsoleCtrl]::AbortRequested -and -not [PkConsoleCtrl]::SkipDrain -and $null -eq [PkConsoleCtrl]::PressedKey) "Reset-PkAbortState clears the presses" + +# A double tap or a held key only counts once. +Reset-Harness +$null = [PkConsoleCtrl]::OnInterrupt('Ctrl+C') +$null = [PkConsoleCtrl]::OnInterrupt('Ctrl+C') +$null = [PkConsoleCtrl]::OnInterrupt('Ctrl+C') +Test-Assert ([PkConsoleCtrl]::AbortRequested -and -not [PkConsoleCtrl]::SkipDrain) "presses in quick succession count as one" + +# The notice names what started the abort, and says how to skip the wait. +Reset-Harness +$null = [PkConsoleCtrl]::OnInterrupt('Ctrl+Break') +$null = Test-PkAbortSignal +$notice = @($script:messages | Where-Object { $_.Message -like '*Ctrl+Break detected*Press Ctrl+C again*' }) +Test-Assert ($notice.Count -eq 1 -and (Get-PkAbortTriggerLabel) -eq 'Ctrl+Break') ("Ctrl+Break is announced as Ctrl+Break, with how to skip the wait (label: " + (Get-PkAbortTriggerLabel) + ")") +Reset-Harness +Request-PkAbort +$null = Test-PkAbortSignal +$notice = @($script:messages | Where-Object { $_.Message -like '*Abort requested*' -and $_.Message -notlike '*Ctrl+C*' }) +Test-Assert ($notice.Count -eq 1 -and (Get-PkAbortTriggerLabel) -eq 'Request-PkAbort') ("an abort from code isn't announced as Ctrl+C (label: " + (Get-PkAbortTriggerLabel) + ")") +# Once an abort from code is under way, a single press skips the wait. +$null = [PkConsoleCtrl]::OnInterrupt('Ctrl+C') +Test-Assert ([PkConsoleCtrl]::SkipDrain) "a press during an abort started from code skips the drain straight away" + +# The force-stop notice says why. +Reset-Harness +$reasons = @((Get-PkForceStopReason -TimeoutSeconds 5)) +$null = [PkConsoleCtrl]::OnInterrupt('Ctrl+C'); [PkConsoleCtrl]::SkipDrain = $true +$reasons += Get-PkForceStopReason -TimeoutSeconds 5 +[PkConsoleCtrl]::IsCloseEvent = $true +$reasons += Get-PkForceStopReason -TimeoutSeconds 5 +Test-Assert ($reasons[0] -like '*within 5s*' -and $reasons[1] -like '*pressed again*' -and $reasons[2] -like '*window is closing*') ("the force-stop notice gives the real reason (" + ($reasons -join ' | ') + ")") + +# Presses from another thread while a run waits, as the native handler would. +function Start-HarnessPresses { + $timer = [PowerShell]::Create() + $null = $timer.AddScript({ + [System.Threading.Thread]::Sleep(700); $null = [PkConsoleCtrl]::OnInterrupt('Ctrl+C') + [System.Threading.Thread]::Sleep(1800); $null = [PkConsoleCtrl]::OnInterrupt('Ctrl+C') + }) + return @{ PS = $timer; Handle = $timer.BeginInvoke() } +} + +# A batch: the second press cuts the 25s drain short, and the script carries on. +Reset-Harness +$presser = Start-HarnessPresses +$sw = [System.Diagnostics.Stopwatch]::StartNew() +$after = @{ Ran = $false } +$batch = Invoke-PkRun { + $b = Invoke-PkProcessBatch -Commands @(1..3 | ForEach-Object { @{ FilePath = $pingExe; Arguments = '-n 15 127.0.0.1' } }) -MaxConcurrent 3 -AbortTimeoutSeconds 25 + $after.Ran = $true + $b +}.GetNewClosure() +$sw.Stop() +$null = $presser.PS.EndInvoke($presser.Handle); $presser.PS.Dispose() +Test-Assert ($batch.Aborted -and $batch.ForceKilledCount -eq 3 -and $sw.Elapsed.TotalSeconds -lt 8 -and $after.Ran) ("batch: a second Ctrl+C force-stops running programs straight away (" + [math]::Round($sw.Elapsed.TotalSeconds, 1) + "s, killed " + $batch.ForceKilledCount + "; the programs run 15s)") +Test-Assert (@($script:messages | Where-Object { $_.Message -like '*force-stopping 3 job(s) now: Ctrl+C was pressed again*' }).Count -eq 1) "and the force-stop says why" + +# Runspace jobs: the same, and the workers are force-stopped. +Reset-Harness +$presser = Start-HarnessPresses +$sw = [System.Diagnostics.Stopwatch]::StartNew() +$rsOut = Invoke-PkRun { + Invoke-PkRunspaceJobs -MaxParallel 3 -JobParams (New-JobList -Jobs @(@{ Id = 1 }, @{ Id = 2 }, @{ Id = 3 })) -DrainTimeoutSeconds 25 ` + -WorkerScript { param([int]$Id) for ($i = 0; $i -lt 150; $i++) { [System.Threading.Thread]::Sleep(100) }; $Id } +}.GetNewClosure() +$sw.Stop() +$null = $presser.PS.EndInvoke($presser.Handle); $presser.PS.Dispose() +Test-Assert ($rsOut.Aborted -and $rsOut.ForceStoppedCount -eq 3 -and $sw.Elapsed.TotalSeconds -lt 8) ("runspace jobs: a second Ctrl+C force-stops running workers straight away (" + [math]::Round($sw.Elapsed.TotalSeconds, 1) + "s, stopped " + $rsOut.ForceStoppedCount + "; the workers run 15s)") + +# The handler thread a window close parks stays parked until the run ends, +# not just the drain, so the rest of the run gets Windows' few seconds. +Reset-Harness +$parked = @{ AfterDrain = $null } +$poll = @{ Count = 0 } +Invoke-PkRun { + $null = Invoke-PkRunspaceJobs -MaxParallel 2 -JobParams (New-JobList -Jobs @(@{ Id = 1 }, @{ Id = 2 })) ` + -WorkerScript { param([int]$Id) for ($i = 0; $i -lt 100; $i++) { if (Test-PkCancelRequested) { break }; [System.Threading.Thread]::Sleep(50) }; $Id } ` + -PollCallback { $poll.Count++; if ($poll.Count -eq 3) { Request-PkAbort } }.GetNewClosure() + $parked.AfterDrain = [PkConsoleCtrl]::DrainComplete.IsSet +}.GetNewClosure() +Test-Assert ($parked.AfterDrain -eq $false -and [PkConsoleCtrl]::DrainComplete.IsSet) ("the handler thread is released when the run ends, not when the drain does (set after drain: " + $parked.AfterDrain + ")") +Reset-PkAbortState +Write-Host "" + Set-PkMessageHandler -Handler $null Reset-PkAbortState diff --git a/ParallelKit.ps1 b/ParallelKit.ps1 index b1e11a9..c7365fd 100644 --- a/ParallelKit.ps1 +++ b/ParallelKit.ps1 @@ -51,9 +51,21 @@ public static class PkConsoleCtrl // Ctrl+C and closing the window behave exactly as they normally would. public static volatile bool RunActive = false; - // The handler parks on this until the main thread finishes draining. + // On a window close, logoff or shutdown the handler parks on this until + // the run ends: the process dies as soon as the handler returns, and + // Windows ends it after a few seconds anyway. Ctrl+C never parks. public static readonly ManualResetEventSlim DrainComplete = new ManualResetEventSlim(false); - public static int DrainWaitMs = 65000; // keep above the drain timeout you use + public static int DrainWaitMs = 65000; + + // Ctrl+C presses: the first starts the abort, the next skips the drain + // (force-stops running jobs now), the one after that is let through to + // PowerShell, which stops the script. A press within SkipDebounceMs of + // the last counted one is a double tap or key repeat, and is ignored. + public static volatile bool SkipDrain = false; + public static volatile string PressedKey = null; // "Ctrl+C" / "Ctrl+Break" once a press started the abort + public static int SkipDebounceMs = 1000; + private static long _lastPressTimestamp = 0; + private static readonly object _pressLock = new object(); private static HandlerRoutine _handler; // strong ref so the GC can't collect it public static bool LastRegisterSucceeded = false; @@ -71,17 +83,60 @@ public static class PkConsoleCtrl { // 0=CTRL_C, 1=CTRL_BREAK, 2=CTRL_CLOSE, 5=CTRL_LOGOFF, 6=CTRL_SHUTDOWN if (!RunActive) { return false; } - if (ctrlType == 0 || ctrlType == 1 || ctrlType == 2 || ctrlType == 5 || ctrlType == 6) + if (ctrlType == 0 || ctrlType == 1) + { + // Never park here: Windows delivers one console event at a time, so + // a parked Ctrl+C would hold back the next press and a window close. + return OnInterrupt(ctrlType == 1 ? "Ctrl+Break" : "Ctrl+C"); + } + if (ctrlType == 2 || ctrlType == 5 || ctrlType == 6) { // IsCloseEvent BEFORE AbortRequested: a poller that sees // AbortRequested must already see the right IsCloseEvent. - IsCloseEvent = (ctrlType == 2 || ctrlType == 5 || ctrlType == 6); + IsCloseEvent = true; AbortRequested = true; DrainComplete.Wait(DrainWaitMs); return true; // suppress the default OS action } return false; } + + // One Ctrl+C/Ctrl+Break press during a run. Public so tests can press it. + // true = handled here; false = let PowerShell's own Ctrl+C stop the script. + public static bool OnInterrupt(string key) + { + lock (_pressLock) + { + long now = System.Diagnostics.Stopwatch.GetTimestamp(); + if (!AbortRequested) + { + PressedKey = key; + _lastPressTimestamp = now; + AbortRequested = true; + return true; + } + // An abort is already under way (this press, Request-PkAbort or a + // close). A press that follows a counted one too closely is ignored. + if (_lastPressTimestamp != 0) + { + double sinceLastMs = (now - _lastPressTimestamp) * 1000.0 / System.Diagnostics.Stopwatch.Frequency; + if (sinceLastMs < SkipDebounceMs) { return true; } + } + _lastPressTimestamp = now; + if (!SkipDrain) { SkipDrain = true; return true; } + return false; + } + } + + public static void ResetPresses() + { + lock (_pressLock) + { + SkipDrain = false; + PressedKey = null; + _lastPressTimestamp = 0; + } + } } // Keeps the PC from idle-sleeping while a run is going. Per-thread: lifted @@ -108,7 +163,7 @@ public static class PkState // an old session would silently run the OLD C# - the check after the // type definitions catches that. That check compares against this // number too; bump both together. - public const int TypesVersion = 3; + public const int TypesVersion = 4; // Locked: Register-/Unregister-PkCancellationSource are callable from // worker runspaces, and List is not safe for concurrent writers. @@ -283,7 +338,7 @@ public static class PkChildProcessJob # compiled types, and they can't be replaced. Say so, instead of failing # later with a missing-method error somewhere unrelated. Keep this number # in sync with [PkState]::TypesVersion above. -if ((& { try { [PkState]::TypesVersion } catch { 0 } }) -ne 3) { +if ((& { try { [PkState]::TypesVersion } catch { 0 } }) -ne 4) { throw "ParallelKit: this PowerShell session already loaded an older version of the kit, and its compiled parts can't be swapped out. Close this window and start a new one." } @@ -471,6 +526,7 @@ function Reset-PkAbortState { [PkState]::CloseAbort = $false [PkConsoleCtrl]::AbortRequested = $false [PkConsoleCtrl]::IsCloseEvent = $false + [PkConsoleCtrl]::ResetPresses() [PkConsoleCtrl]::DrainComplete.Reset() } @@ -500,11 +556,16 @@ function Test-PkAbortSignal { foreach ($cts in [PkState]::SnapshotSources()) { try { $cts.Cancel() } catch { } # an already-disposed source has nothing left to cancel } - $abortMessage = if (-not [PkState]::CloseAbort) { - "[ABORT] Ctrl+C detected - finishing current jobs, please wait..." - } else { + $pressedKey = [PkConsoleCtrl]::PressedKey + $abortMessage = if ([PkState]::CloseAbort) { # No drain on a close: Windows ends the process within seconds. "[ABORT] Window close detected - stopping current jobs now..." + } elseif ($null -ne $pressedKey) { + "[ABORT] " + $pressedKey + " detected - finishing current jobs, please wait... Press Ctrl+C again to stop them now." + } elseif ([PkConsoleCtrl]::RunActive) { + "[ABORT] Abort requested - finishing current jobs, please wait... Press Ctrl+C to stop them now." + } else { + "[ABORT] Abort requested - finishing current jobs, please wait..." } Invoke-PkMessageHandler -Message $abortMessage -Level Warn } @@ -526,11 +587,22 @@ function Test-PkCancelRequested { function Get-PkAbortTriggerLabel { # .SYNOPSIS - # Says what started the abort: "Ctrl+C" or "window close (X button)". + # Says what started the abort: "Ctrl+C", "Ctrl+Break", "window close (X button)", + # or "Request-PkAbort" for an abort started from code. # .OUTPUTS # [string] if ([PkState]::CloseAbort) { return "window close (X button)" } - return "Ctrl+C" + $pressedKey = [PkConsoleCtrl]::PressedKey + if ($null -ne $pressedKey) { return $pressedKey } + return "Request-PkAbort" +} + +function Get-PkForceStopReason { + # Why an abort is force-stopping jobs, for the engines' force-stop notice. + param([int]$TimeoutSeconds) + if ([PkConsoleCtrl]::IsCloseEvent) { return " now: the window is closing" } + if ([PkConsoleCtrl]::SkipDrain) { return " now: Ctrl+C was pressed again" } + return (" that did not finish within " + $TimeoutSeconds + "s") } function Complete-PkAbortDrain { @@ -555,7 +627,8 @@ function Invoke-PkRun { # the kit's engines watch: no new work starts, running work gets time to finish, # and whatever is left is then stopped. A long step of your own in here must # check Test-PkAbortSignal now and then and stop when it's true, or Ctrl+C will - # seem to do nothing until that step ends. Closing the window skips the wait. + # seem to do nothing until that step ends. Pressing Ctrl+C again, or closing the + # window, skips the wait; a third press stops the script as Ctrl+C normally does. # # For the length of the run it also keeps the PC from idle-sleeping and turns off # the console's QuickEdit mode. Whatever the block outputs is passed through. @@ -1301,7 +1374,7 @@ function Invoke-PkProcessDispatchCore { if ($CancelToken.IsCancellationRequested -and $running.Count -gt 0) { try { $abortDeadline = [System.Diagnostics.Stopwatch]::StartNew() - while ($running.Count -gt 0 -and -not [PkConsoleCtrl]::IsCloseEvent -and $abortDeadline.Elapsed.TotalSeconds -lt $AbortTimeoutSeconds) { + while ($running.Count -gt 0 -and -not [PkConsoleCtrl]::IsCloseEvent -and -not [PkConsoleCtrl]::SkipDrain -and $abortDeadline.Elapsed.TotalSeconds -lt $AbortTimeoutSeconds) { # ---- BLOCK: graceful-wait loop ---- (keep in sync with the # main poll pass above and the final pass below) $stillWaiting = [System.Collections.Generic.List[hashtable]]::new() @@ -1405,7 +1478,7 @@ 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 + Write-PkMessage ("ProcessDispatch: force-stopping " + $running.Count + " job(s)" + (Get-PkForceStopReason -TimeoutSeconds $AbortTimeoutSeconds) + ".") -Level Warn Stop-PkJobProcesses -Jobs $running foreach ($job in $running) { try { if ($job['Proc']) { $null = $job['Proc'].WaitForExit(5000) } } catch { } @@ -1487,7 +1560,7 @@ function Invoke-PkProcessBatch { # optionally, WorkingDirectory, OutputEncoding (overrides -OutputEncoding) and # any keys of your own, which come back on the result's Command. Inside # Invoke-PkRun, Ctrl+C lets running programs finish (up to -AbortTimeoutSeconds) - # and starts no new ones. + # and starts no new ones; a second Ctrl+C kills them straight away. # .PARAMETER Commands # The command hashtables. $null or empty returns no results. # .PARAMETER MaxConcurrent @@ -1612,7 +1685,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') + 'Register-PkCancellationSource', 'Unregister-PkCancellationSource', 'New-PkBatchResult', 'ConvertTo-PkArgumentString', 'Get-PkForceStopReason') } function Receive-PkCompletedHandles { @@ -1648,8 +1721,9 @@ function Receive-PkCompletedHandles { function Invoke-PkGracefulDrain { # After an abort: give running workers $TimeoutSeconds to finish, collect - # what they return, force-stop the rest. Always releases the native - # handler thread, even if something in here throws. + # what they return, force-stop the rest. Leaves the native handler thread + # parked: on a window close it's released when Invoke-PkRun ends, so the + # rest of the run still gets the few seconds Windows allows. param( [Parameter(Mandatory)] [AllowEmptyCollection()] @@ -1661,7 +1735,6 @@ function Invoke-PkGracefulDrain { ) if ($Handles.Count -eq 0) { - Complete-PkAbortDrain return @{ ForceStoppedCount = 0 } } @@ -1674,36 +1747,32 @@ function Invoke-PkGracefulDrain { $abortDeadline = [System.Diagnostics.Stopwatch]::StartNew() $forceStoppedCount = 0 - try { - if ($AbortWaitMessage -ne "") { Invoke-PkMessageHandler -Message $AbortWaitMessage -Level Warn } + if ($AbortWaitMessage -ne "") { Invoke-PkMessageHandler -Message $AbortWaitMessage -Level Warn } - while ($localHandles.Count -gt 0 -and -not [PkConsoleCtrl]::IsCloseEvent -and $abortDeadline.Elapsed.TotalSeconds -lt $TimeoutSeconds) { - $localHandles = Receive-PkCompletedHandles -Handles $localHandles -ResultHandler $ResultHandler -ErrorHandler $errorHandler - Receive-PkWorkerMessages -Queue $MessageQueue - if ($localHandles.Count -gt 0) { [System.Threading.Thread]::Sleep(250) } - } + while ($localHandles.Count -gt 0 -and -not [PkConsoleCtrl]::IsCloseEvent -and -not [PkConsoleCtrl]::SkipDrain -and $abortDeadline.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + $localHandles = Receive-PkCompletedHandles -Handles $localHandles -ResultHandler $ResultHandler -ErrorHandler $errorHandler + Receive-PkWorkerMessages -Queue $MessageQueue + if ($localHandles.Count -gt 0) { [System.Threading.Thread]::Sleep(250) } + } - # One final pass before declaring anything force-stopped. - if ($localHandles.Count -gt 0) { - $localHandles = Receive-PkCompletedHandles -Handles $localHandles -ResultHandler $ResultHandler -ErrorHandler $errorHandler - Receive-PkWorkerMessages -Queue $MessageQueue - } + # One final pass before declaring anything force-stopped. + if ($localHandles.Count -gt 0) { + $localHandles = Receive-PkCompletedHandles -Handles $localHandles -ResultHandler $ResultHandler -ErrorHandler $errorHandler + Receive-PkWorkerMessages -Queue $MessageQueue + } - if ($localHandles.Count -gt 0) { - $forceStoppedCount = $localHandles.Count - Invoke-PkMessageHandler -Level Error -Message ("[ABORT] Force-stopping " + $forceStoppedCount + " job(s) that did not finish within " + $TimeoutSeconds + "s.") - foreach ($h in $localHandles) { - # BeginStop, not Stop: Stop() blocks until the pipeline - # actually unwinds, and a worker stuck in a .NET call never does. - try { $null = $h.PS.BeginStop($null, $null) } catch { } - } + if ($localHandles.Count -gt 0) { + $forceStoppedCount = $localHandles.Count + Invoke-PkMessageHandler -Level Error -Message ("[ABORT] Force-stopping " + $forceStoppedCount + " job(s)" + (Get-PkForceStopReason -TimeoutSeconds $TimeoutSeconds) + ".") + foreach ($h in $localHandles) { + # BeginStop, not Stop: Stop() blocks until the pipeline + # actually unwinds, and a worker stuck in a .NET call never does. + try { $null = $h.PS.BeginStop($null, $null) } catch { } } - - Receive-PkWorkerMessages -Queue $MessageQueue - } finally { - Complete-PkAbortDrain } + Receive-PkWorkerMessages -Queue $MessageQueue + return @{ ForceStoppedCount = $forceStoppedCount } } diff --git a/README.md b/README.md index 2621011..3d351ce 100644 --- a/README.md +++ b/README.md @@ -276,6 +276,8 @@ Inside `Invoke-PkRun { ... }`, Ctrl+C: 3. waits for in-flight work (`-DrainTimeoutSeconds` / `-AbortTimeoutSeconds`, default 60), 4. force-stops whatever is left, calling `-JobAbortCleanup` for each killed or never-started process job. +Press Ctrl+C again to skip step 3: whatever is still running is force-stopped straight away, and your script carries on. A third press stops the script the way Ctrl+C normally does. Presses less than a second apart count as one, and Ctrl+Break works the same as Ctrl+C. + Closing the window does the same minus step 3: Windows allows only a few seconds, so leftovers are stopped straight away. Three things to know: @@ -344,7 +346,7 @@ As a module it couldn't find the functions you list in `-SharedFunctionNames`, s **What happens to running programs when I press Ctrl+C?** -Inside `Invoke-PkRun`, no new ones start, and the running ones get up to 60 seconds by default to finish. Anything still running after that is killed, and `-JobAbortCleanup` is called for each so you can delete its partial output. See [Abort](#abort). +Inside `Invoke-PkRun`, no new ones start, and the running ones get up to 60 seconds by default to finish. Anything still running after that is killed, and `-JobAbortCleanup` is called for each so you can delete its partial output. Press Ctrl+C again to kill them straight away instead of waiting. See [Abort](#abort). **And if the script is killed outright, from Task Manager say?** From 1c2899c8e520570c70c6c615696e3854ba6ff371 Mon Sep 17 00:00:00 2001 From: Hal <22532613+HalRiveria@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:06:53 -0400 Subject: [PATCH 2/2] Give the simulated-press tests more time before the first press Co-Authored-By: Claude Opus 5.5 --- ParallelKit.Harness.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ParallelKit.Harness.ps1 b/ParallelKit.Harness.ps1 index 60fc555..b24bd6a 100644 --- a/ParallelKit.Harness.ps1 +++ b/ParallelKit.Harness.ps1 @@ -1445,7 +1445,8 @@ Test-Assert ($reasons[0] -like '*within 5s*' -and $reasons[1] -like '*pressed ag function Start-HarnessPresses { $timer = [PowerShell]::Create() $null = $timer.AddScript({ - [System.Threading.Thread]::Sleep(700); $null = [PkConsoleCtrl]::OnInterrupt('Ctrl+C') + # 1.5s gives a slow machine time to start everything before the abort. + [System.Threading.Thread]::Sleep(1500); $null = [PkConsoleCtrl]::OnInterrupt('Ctrl+C') [System.Threading.Thread]::Sleep(1800); $null = [PkConsoleCtrl]::OnInterrupt('Ctrl+C') }) return @{ PS = $timer; Handle = $timer.BeginInvoke() }