8000 Expose an event corresponding to the runspace stopping · Issue #24646 · PowerShell/PowerShell · GitHub
[go: up one dir, main page]

Skip to content

Expose an event corresponding to the runspace stopping #24646

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
alx9r opened this issue Dec 5, 2024 · 17 comments
Closed

Expose an event corresponding to the runspace stopping #24646

alx9r opened this issue Dec 5, 2024 · 17 comments
Labels
Issue-Enhancement the issue is more of a feature request than a bug

Comments

@alx9r
Copy link
alx9r commented Dec 5, 2024

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
@alx9r alx9r added Issue-Enhancement the issue is more of a feature request than a bug Needs-Triage The issue is new and needs to be triaged by a work group. labels Dec 5, 2024
@santisq
Copy link
santisq commented Dec 5, 2024

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

@alx9r
Copy link
Author
alx9r commented Dec 5, 2024

Thanks @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.

@alx9r alx9r closed this as completed Dec 5, 2024
@microsoft-github-policy-service microsoft-github-policy-service bot removed the Needs-Triage The issue is new and needs to be triaged by a work group. label Dec 5, 2024
@jborean93
Copy link
Collaborator
jborean93 commented 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.

$task = [System.Threading.Tasks.Task]::Delay(10000)
while (-not $task.IsCompleted) { Start-Sleep -Milliseconds 300 }
$task.GetAwaiter().GetResult()

@alx9r
Copy link
Author
alx9r commented Dec 5, 2024

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.

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

@alx9r
Copy link
Author
alx9r commented Dec 5, 2024

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

@kasini3000
Copy link

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
Copy link
Author
alx9r commented 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
Copy link

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
Copy link
Collaborator
jborean93 commented 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

Learn about .NET thread synchronization primitives used to synchronize access to a shared resource or control thread interaction

@alx9r
Copy link
Author
alx9r commented Dec 8, 2024

@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
Copy link
Author
alx9r commented 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
Copy link
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.

@kasini3000
Copy link

@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
Copy link
santisq commented Dec 10, 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

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.

@kasini3000
Copy link

@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
Copy link
santisq commented Dec 10, 2024

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.

@kasini3000
Copy link

@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
Labels
Issue-Enhancement the issue is more of a feature request than a bug
Projects
None yet
Development

No branches or pull requests

4 participants
0