Skip to content

Expose an event corresponding to the runspace stopping #24646

Closed

Description

@alx9r

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 millisecondsDelay

which 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 millisecondsDelay

which 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

  1. added
    Issue-Enhancementthe issue is more of a feature request than a bug
    Needs-TriageThe issue is new and needs to be triaged by a work group.
    on Dec 5, 2024
  2. santisq commented on Dec 5, 2024

    @santisq

    This is something Jordan Borean (@jborean93)'s PR: #24620 might be able to solve (?)

  3. alx9r commented on Dec 5, 2024

    @alx9r
    Author

    Thanks Santiago Squarzon (@santisq). Somehow I missed that. It looks like there are a handful of related proposals as follows:

    I'm closing this as a duplicate of #24620 and PowerShell/PowerShell-RFC#382. I hope that makes it into PowerShell.

  4. jborean93 commented on Dec 5, 2024

    @jborean93
    Collaborator

    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.

    $task = [System.Threading.Tasks.Task]::Delay(10000)
    while (-not $task.IsCompleted) { Start-Sleep -Milliseconds 300 }
    $task.GetAwaiter().GetResult()
  5. alx9r commented on Dec 5, 2024

    @alx9r
    Author

    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.

    Jordan Borean (@jborean93) If I'm understanding this correctly, there might be a way to emit a token from a compiled command (e.g. CancellationContext below) at the top of a PowerShell call stack that would be available to any descendants. So it might look like the following:

    Start-Job {
        CancellationContext {
            param($CancellationToken)
            # do all user work here
        }
     }

    Your proposal is way nicer ergonomics, but this might be an adequate stop-gap. At least the API used is public.

  6. alx9r commented on Dec 5, 2024

    @alx9r
    Author

    ...there might be a way to emit a token from a compiled command...at the top of a PowerShell call stack that would be available to any descendants...

    Yup. This works. There is a demo here. I'd prefer the solution from the RFC, but this works even in Windows PowerShell.

  7. kasini3000 commented on Dec 7, 2024

    @kasini3000

    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."

  8. alx9r commented on Dec 7, 2024

    @alx9r
    Author

    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.

  9. kasini3000 commented on Dec 8, 2024

    @kasini3000

    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!

  10. jborean93 commented on Dec 8, 2024

    @jborean93
    Collaborator

    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

    Learn about .NET thread synchronization primitives used to synchronize access to a shared resource or control thread interaction
  11. alx9r commented on Dec 8, 2024

    @alx9r
    Author

    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?

  12. alx9r commented on Dec 8, 2024

    @alx9r
    Author

    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.

  13. jborean93 commented on Dec 8, 2024

    @jborean93
    Collaborator

    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?

    The documentation on the types and methods themselves. Granted the one for EventWaitHandle is 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.

  14. kasini3000 commented on Dec 9, 2024

    @kasini3000

    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

  15. santisq commented on Dec 10, 2024

    @santisq

    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

    I did my take including a timeout, last example from: https://github.com/santisq/PSUsing?tab=readme-ov-file#usage

    GitHub
    C# using statement for PowerShell. Contribute to santisq/PSUsing development by creating an account on GitHub.
  16. kasini3000 commented on Dec 10, 2024

    @kasini3000

    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.

  17. santisq commented on Dec 10, 2024

    @santisq

    Hi kasini3000, I'll not be doing any PR but in case you want to have a ThreadJob that includes a CancellationToken and supports timeout, you can clone this fork: https://github.com/santisq/ThreadJob. It can basically allow you to do this:

    vpzcX5jNXB

    Hope it helps!

    GitHub
    Contribute to santisq/ThreadJob development by creating an account on GitHub.
  18. kasini3000 commented on Dec 11, 2024

    @kasini3000

    Santiago Squarzon (@santisq) I'll give it a try

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Issue-Enhancementthe issue is more of a feature request than a bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions