Repository navigation
Description
Summary of the new feature / enhancement
As a user of long-running .Net calls that accept System.Threading.CancellationToken I would like to be able to configure those calls to be canceled when I stop the PowerShell job where they are running. To accomplish that I would need some way to obtain or fashion a CancellationToken whose cancellation corresponds to the job's runspace entering the "Stopping" state.
Consider the desired use-case
$job =
Start-Job {
$token =
Get-RunspaceCancellationToken `
-State Stopping
# this is a stand-in for a cancellable long-running .Net task
[System.Threading.Tasks.Task]::Delay(
3000 , # millisecondsDelay
$token
).Wait()
}
# ...
$job | Stop-Job # this should cancel $token
$job | Wait-Job # this should complete without waiting for the whole millisecondsDelaywhich would complete quickly after calling Stop-Job. Contrast that with what is currently possible with public APIs
$job =
Start-Job {
# this is a stand-in for a cancellable long-running .Net task
[System.Threading.Tasks.Task]::Delay(
3000 # millisecondsDelay
).Wait()
}
# ...
$job | Stop-Job # this hangs waiting for Delay() to complete its full millisecondsDelaywhich waits the full millisecondsDelay before completing.
Proposed technical implementation details
The above seems to be possible using reflection to subscribe to a non-public Stopping state transition event. A demonstration of that is shown in the code below. That demo outputs
TotalSeconds
------------
0.64
begin
clean
which indicates the following:
- the job is successfully stopped long before the full
millisecondsDelay - the last line of
end{}is never reached clean{}is invoked indicating graceful stopping of the runspace succeeded
Demo
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$jobRunning = [System.Threading.ManualResetEventSlim]::new()
$job =
Start-ThreadJob `
-ArgumentList $jobRunning `
-ScriptBlock {
param($jobRunning)
begin { 'begin' | Set-Content .\log.txt }
end {
function Get-RunspaceCancellationToken {
[OutputType([System.Threading.CancellationToken])]
param(
[runspace]
$Runspace = ([runspace]::DefaultRunspace),
[Parameter()]
[System.Management.Automation.Runspaces.PipelineState]
$State = [System.Management.Automation.Runspaces.PipelineState]::Stopping
)
Add-Type @'
using System.Threading;
using System;
using System.Management.Automation.Runspaces;
namespace n {
public static class RunspaceExtensions {
public static EventHandler<PipelineStateEventArgs>
GetPipelineStateChangeAction(
CancellationTokenSource cts,
PipelineState state) {
return (s,e)=> {
if (e.PipelineStateInfo.State != state) {
return;
}
cts.Cancel();
};
}
}
}
'@
$cts = [System.Threading.CancellationTokenSource]::new()
$method = [runspace].GetMethod(
'GetCurrentlyRunningPipeline',
[System.Reflection.BindingFlags] 'NonPublic, Instance')
$pipeline = $method.Invoke($Runspace, $null)
$action = [n.RunspaceExtensions]::GetPipelineStateChangeAction($cts,$State)
$pipeline.add_StateChanged($action)
$cts.Token
}
$token =
Get-RunspaceCancellationToken `
-State Stopping
$jobRunning.Set()
[System.Threading.Tasks.Task]::Delay(
3000 , # int millisecondsDelay
$token # System.Threading.CancellationToken cancellationToken
).Wait()
'end' | Add-Content .\log.txt
}
clean { 'clean' | Add-Content .\log.txt }
}
$jobRunning.Wait() | Out-Null
$job | Stop-Job | Wait-Job | Receive-Job
$stopwatch.Elapsed | Select-Object TotalSeconds
Get-Content .\log.txt
Activity
santisq commentedon Dec 5, 2024
This is something Jordan Borean (@jborean93)'s PR: #24620 might be able to solve (?)
alx9r commentedon Dec 5, 2024
Thanks Santiago Squarzon (@santisq). Somehow I missed that. It looks like there are a handful of related proposals as follows:
[MyType]::SomeMethodWithCancellation($PSCmdlet.PipelineStopToken)" which would mean I could replace$tokenin my OP demo with$PSCmdlet.PipelineStopToken. That sounds pretty great to me. Add RFC for PipelineStopToken PowerShell-RFC#382 seems to confirm the same.I'm closing this as a duplicate of #24620 and PowerShell/PowerShell-RFC#382. I hope that makes it into PowerShell.
jborean93 commentedon Dec 5, 2024
Yep the PR exposes a CancellationToken that is hooked up to the stop event allowing you to easily pass it through to async tasks. See the RFC (still to be approved) for some more background details around the problem and the approach the PR has taken PowerShell/PowerShell-RFC#382.
Right now the only public API is overriding the StopProcessing method in a compiled cmdlet which is called when the engine is requested to stop. Unfortunately this is not available in a script function so your only option for calling and awaiting a .NET task is to not do a blocking wait on the .net call but have a small spin lock that waits for the task to complete, e.g.
alx9r commentedon Dec 5, 2024
Jordan Borean (@jborean93) If I'm understanding this correctly, there might be a way to emit a token from a compiled command (e.g.
CancellationContextbelow) at the top of a PowerShell call stack that would be available to any descendants. So it might look like the following:Your proposal is way nicer ergonomics, but this might be an adequate stop-gap. At least the API used is public.
alx9r commentedon Dec 5, 2024
Yup. This works. There is a demo here. I'd prefer the solution from the RFC, but this works even in Windows PowerShell.
kasini3000 commentedon Dec 7, 2024
Is there a script block for Linux?
$jobRunning = [System.Threading.EventWaitHandle]::new($false,'ManualReset','event_1f242755')
Exception calling ".ctor" with "3" argument(s): "The named version of this synchronization primitive is not supported on this platform."
alx9r commentedon Dec 7, 2024
kasini3000 I’m surprised by that error message. The API catalog entry for that overload says “This API is supported on all platforms.” For the use in my OP you can use another synchronization method like checking for files or probably even sleeps.
kasini3000 commentedon Dec 8, 2024
1 On Windows, the script block works as expected.
2 The above error is reported on Rocky Linux 9.2 x64 + PowerShell 7.5 preview 5.
3 use $jobRunning = [System.Threading.EventWaitHandle]::new($false,'ManualReset') on this linux , hang!
jborean93 commentedon Dec 8, 2024
The docs are somewhat unclear but I don’t believe named EventWaitHandle is supported outside of Windows. It seems like the only cross platform synchronisation primitive that is supported is a Mutex https://learn.microsoft.com/en-us/dotnet/standard/threading/overview-of-synchronization-primitives#waithandle-class-and-lightweight-synchronization-types
alx9r commentedon Dec 8, 2024
Jordan Borean (@jborean93) Short of attempting to call each API on each platform, do you see a better way than apisof.net to check whether an API is supported?
alx9r commentedon Dec 8, 2024
kasini3000 I've update both the OP demo and this demo to use a primitive that I expect is more linux-friendly. But those demos now rely on thread jobs, which, I think, are still shipped as a separate module.
jborean93 commentedon Dec 8, 2024
The documentation on the types and methods themselves. Granted the one for
EventWaitHandleis vague enough in that it looks like a named event handle would work but maybe that's only for process scoped ones or some sort of documentation inheritance thing.kasini3000 commentedon Dec 9, 2024
alx9r good,thanks!
After my testing, the demo works as expected on Windows and Linux.
for next :
How to add parameters such as “-timeout 5(seconds)” to demo function
santisq commentedon Dec 10, 2024
I did my take including a timeout, last example from: https://github.com/santisq/PSUsing?tab=readme-ov-file#usage
kasini3000 commentedon Dec 10, 2024
Santiago Squarzon (@santisq) Thank you for sharing.
Would you be willing to PR "your C# code" to
PowerShell/ThreadJob#26
when you have free time?
Or
who is willing to do this?
Adding timeouts to the runspace-thread is important , But there is no hope in the long run.
santisq commentedon Dec 10, 2024
Hi kasini3000, I'll not be doing any PR but in case you want to have a
ThreadJobthat includes aCancellationTokenand supports timeout, you can clone this fork: https://github.com/santisq/ThreadJob. It can basically allow you to do this:Hope it helps!
kasini3000 commentedon Dec 11, 2024
Santiago Squarzon (@santisq) I'll give it a try