Reputation: 4241
The following code demonstrates that a long-running (but, in principle, cancellable) .Net method called from a job runs to completion even when the job is stopped:
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$jobRunning = [System.Threading.EventWaitHandle]::new($false,'ManualReset','event_1f242755')
$job = Start-Job {
$jobRunning = [System.Threading.EventWaitHandle]::new($false,'ManualReset','event_1f242755')
$jobRunning.Set()
[System.Threading.Tasks.Task]::Delay(
3000 # int millisecondsDelay
<#?#> # System.Threading.CancellationToken cancellationToken
).Wait()
}
$jobRunning.WaitOne() | Out-Null
Start-Sleep -Milliseconds 200
$job | Stop-Job | Wait-Job | Out-Null
$stopwatch.Elapsed | Select-Object TotalSeconds
The typical output
TotalSeconds : 3.2555954
indicates that Task::Delay()
waited the whole 3000ms before completing. Such .Net tasks are customarily cancellable by way of a CancellationToken
provided during task creation. Task.Delay()
has an overload that would accept such a token. I have had some success creating cancellation tokens by subscribing to PosixSignal.SIGINT
. That event seems to be raised by CTRL+C in the console pwsh.exe
. The same event, however, does not seem be raised in a pwsh.exe
job.
Is there an event I can subscribe to from a job that is raised when Stop-Job is invoked on that job?
This answer demonstrates the use of such an event using reflection to subscribe to a non-public event. A proposal to expose such an event with a public API is here. And there is an even more elaborate proposal, RFC, and pull request. Hopefully one of these improvements makes it into PowerShell sometime soon.
Upvotes: 3
Views: 94
Reputation: 4241
After reviewing the proposal, RFC, and pull request for this feature, it became apparent that a cancellation token could, in principle, be fashioned using the Cmdlet
public APIs and could be used thus:
Using-CancellationToken {
param($CancellationToken)
[System.Threading.Tasks.Task]::Delay(
3000,
$CancellationToken
).Wait()
}
The demo code below shows the implementation of Using-CancellationToken
. It simply fashions a token that is canceled by StopProcessing()
. That token can only be used for the lifetime of the of the invocation of the Using-CancellationToken
. This suggests Using-CancellationToken
is best used at the top of the PowerShell call stack with all of the cancellable work happening inside the ScriptBlock provided as a parameter.
Obtaining the token from $PSCmdlet
as is proposed in the RFC would surely be more ergonomic. But this method works with PowerShell versions at least as far back as PowerShell 5.1.
The demo code below outputs
begin
clean
TotalSeconds
------------
0.58
which indicates
.Delay()
was canceled,.Delay()
since the end{}
block did not run, andclean{}
block ran.$jobRunning = [System.Threading.ManualResetEventSlim]::new()
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$job =
Start-ThreadJob `
-ArgumentList $jobRunning `
-ScriptBlock {
param($jobRunning)
begin {
'begin' | Set-Content .\log.txt
Add-Type `
-PassThru `
-TypeDefinition @'
using System.Threading;
using System.Management.Automation;
namespace n {
[Cmdlet("Using","CancellationToken")]
public class UsingCancellationTokenCommand : Cmdlet
{
[Parameter(Mandatory=true,Position=1)]
public ScriptBlock ScriptBlock { get; set; }
CancellationTokenSource cts;
protected override void BeginProcessing() {
cts = new CancellationTokenSource();
}
protected override void EndProcessing() {
var output = ScriptBlock.Invoke(cts.Token);
foreach (var o in output) {
WriteObject(o);
}
}
protected override void StopProcessing () {
cts.Cancel();
}
}
}
'@ |
% Assembly |
Import-Module -WarningAction SilentlyContinue
}
end {
Using-CancellationToken {
param($CancellationToken)
$jobRunning.Set() | Out-Null
[System.Threading.Tasks.Task]::Delay(
3000 , # int millisecondsDelay
$CancellationToken
).Wait()
}
'end' | Add-Content .\log.txt
}
clean { 'clean' | Add-Content .\log.txt}
}
$jobRunning.Wait() | Out-Null
Start-Sleep -Milliseconds 100
$job | Stop-Job | Wait-Job
Get-Content .\log.txt
$stopwatch.Elapsed | Select-Object TotalSeconds
Upvotes: 0
Reputation: 60145
No public APIs you can subscribe to from the Job side, you can thru reflection get the currently running pipeline and from there subscribe to StateChanged
:
$tmp = New-TemporaryFile
$job = Start-Job {
$tmp = $using:tmp.FullName
$method = [runspace].GetMethod(
'GetCurrentlyRunningPipeline',
[System.Reflection.BindingFlags] 'NonPublic, Instance')
$obj = $method.Invoke([runspace]::DefaultRunspace, $null)
$null = Register-ObjectEvent -InputObject $obj -EventName StateChanged -Action {
param(
[System.Management.Automation.Runspaces.Pipeline] $s,
[System.Management.Automation.Runspaces.PipelineStateEventArgs] $e)
if ($e.PipelineStateInfo.State -eq 'Stopping') {
"Job was stopped at $([datetime]::Now)" | Set-Content $tmp
}
}
Start-Sleep 5
}
Start-Sleep 1
$job | Stop-Job -PassThru | Remove-Job
Get-Content $tmp.FullName
$tmp.Delete()
Upvotes: 3