alx9r
alx9r

Reputation: 4241

Is there an event I can subscribe to from a job that is raised when Stop-Job is invoked on that job?

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

Answers (2)

alx9r
alx9r

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.

Demo

The demo code below outputs

begin
clean

TotalSeconds
------------
        0.58

which indicates

  • the .Delay() was canceled,
  • the job completed well in advance of the 3 second delay,
  • stopping occurred during .Delay() since the end{} block did not run, and
  • the clean{} 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

Santiago Squarzon
Santiago Squarzon

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

Related Questions