Passing keyboard input for a running process (powershell script)

122 views Asked by At

I have 3 powershell scripts. First one run second(monitor for third) and third(do some actions). When execution of third one is done (it's called in blocking way (we are waiting for exit)), i want to tell second(monitor that is called in non-blocking way) to end his action and return some values. How should I do it? I am checking in monitor script if user pressed s key and return some values if true. I need to "press" that key in first script. My code is like this:

#some code

$psi = new-object "Diagnostics.ProcessStartInfo"
$psi.FileName = "artillery"
$arguments = "my fancy args" 
$psi.Arguments = $arguments

$proc = [Diagnostics.Process]::Start($psi)

$psi2 = new-object "Diagnostics.ProcessStartInfo"
$psi2.FileName = "artillery_metric_getter.ps1" 
$proc2 = [Diagnostics.Process]::Start($psi2)
$proc2.Start();

if ( $waitForExit ) {
        $proc.WaitForExit();
        $stdout = $proc.StandardOutput.ReadToEnd()
        $stderr = $proc.StandardError.ReadToEnd()
        Write-Host "stdout: $stdout"
        Write-Host "stderr: $stderr"
                }
#here i want to "press 's' key for $proc2 and catch values he return

The code of powershell script that is called in $proc2 is like:

for(($i=1),($i>0),($i++)){

#there is some code

$key = $Host.UI.RawUI.ReadKey()
if ($key.Character -eq 's') {
    
  $meanMemRet = 123 #there is some calc 
  $meanProcRet = 456 #there is some calc
  
  return $meanProcRet,$meanMemRet
}
}

TLDR: So we need to "press" 's' button in first script to stop execution of monitor script. How to do it?

2

There are 2 answers

7
Santiago Squarzon On

Given that you're okay with having a solution using a Runspace, this is how you can approach your code. Worth noting, passing arguments to the runspace is fairly easy, certainly easier than passing arguments to a Process.

Example using .AddParameters:

[powershell]::Create().
    AddScript({
        param($Param1, $Param2)

        "$Param1 $Param2"
    }).
    AddParameters(@{ Param1 = 'Hello'; Param2 = 'World' }).
    Invoke()

In your case, you can add a param block to your artillery_metric_getter.ps1. It's also worth noting that, the script invoking artillery_metric_getter.ps1 must block, awaiting for either an s key press or the task completion.

$ps = [powershell]::Create().
    AddScript('absolute\path\to\artillery_metric_getter.ps1', $false)

# hook the runspace with your PSHost, this is so all streams
# except Success and Error are redirected to your console
$rs = [runspacefactory]::CreateRunspace($Host)
$rs.Open()
$ps.Runspace = $rs
$out = [System.Management.Automation.PSDataCollection[psobject]]::new()
$task = $ps.BeginInvoke(
    [System.Management.Automation.PSDataCollection[psobject]] $null, $out)

# wait here for the task to be completed
while (-not $task.AsyncWaitHandle.WaitOne(200)) {
    # or, if `s` key is pressed
    if ([Console]::KeyAvailable -and [Console]::ReadKey($true).KeyChar -eq 's') {
        # stop the task
        $ps.Stop()
    }

    # you can add this here to know its actually looping
    'awaiting key press...'
    # and do other stuff here, otherwsie just leave this empty
}

if ($ps.HadErrors) {
    $ps.Streams.Error # You can find errors here
}

# and here is the output generated by your task
$out.ReadAll()

# dispose resources
if ($ps) {
    $ps.Dispose()
}

if ($rs) {
    $rs.Dispose()
}

Following up from comments, since the runspace is hooked to your $Host listening on s key press is also possible inside it, using this as an example for artillery_metric_getter.ps1:

while ($true) {
    if ([Console]::KeyAvailable -and $Host.UI.RawUI.ReadKey().Character -eq 's') {
        return 'hello world'
    }

    [datetime]::Now.ToString('u')
    Start-Sleep -Milliseconds 200
}

Then from your main thread you leave the while loop empty, blocking until the task is done (until s key is pressed):

$ps = [powershell]::Create().
    AddScript('absolute\path\to\artillery_metric_getter.ps1', $false)

$rs = [runspacefactory]::CreateRunspace($Host)
$rs.Open()
$ps.Runspace = $rs
$out = [System.Management.Automation.PSDataCollection[psobject]]::new()
$task = $ps.BeginInvoke(
    [System.Management.Automation.PSDataCollection[psobject]] $null, $out)

while (-not $task.AsyncWaitHandle.WaitOne(200)) { }

$out.ReadAll()
0
mklement0 On

Let me offer an alternative approach, using the Start-ThreadJob cmdlet, which is in essence a friendly wrapper around the runspace APIs that can be used with the same job-management cmdlets as the child process-based background jobs (created with Start-Job).

  • Start-ThreadJob comes with PowerShell (Core) 7+ and in Windows PowerShell can be installed on demand with, e.g., Install-Module ThreadJob -Scope CurrentUser.

The following, self-contained sample code:

  • Assumes that you have control over the monitoring code to be run in the background and that you simply need some signaling mechanism for the main thread to instruct the monitoring code to exit and return a result, which is achieved by way of a custom event.

  • It shows only the interaction between the main thread and the background monitoring code, created with Start-ThreadJob (the equivalent of your artillery_metric_getter.ps1 script).

    • It sounds like in your scenario you can simply run your artillery command by direct, synchronous invocation, which allows you to capture the output streams directly, if needed:

      # Invoke artillery directly, synchronously; output to the console.
      # Process exit code will be reflected in $LASTEXTICODE
      artillery my fancy args
      
      # Alternatively, capture both stdout and stderr output.
      $stdoutLines, $stderrLines = 
         (artillery my fancy args 2>&1).Where({ $_ -is [string] }, 'Split')
      
  • The simulated monitoring code checks for the custom exit event raised by the main thread in a polling loop, via Get-Event, and produces output indefinitely until the custom event is received.

    • To synchronously wait for the event - akin to waiting for a keystroke - use Wait-Event, though note that this may not be necessary, given that jobs produce their output in the background anyway, and require the caller to collect the output on demand via Receive-Job.
      That is, in general you could just let the thread job run its course and let it exit automatically, then collect the results when needed.
  • See the source-code comments for further information.

# Choose the name of a custom event.
# Note that NO event registration is strictly necessary: 
#  * Events with that name can be generated without further preparation, with
#    New-Event or - in the case at hand, due to cross-runspace use - via 
#   .Events.GenerateEvent() on the target runspace.
#  * The target runspace can use Wait-Event or Get-Event to check for such
#    events, or use Register-EngineEvent -Action { ... } to process events
#    asynchronously, via a dynamic module in which the script block runs.
$eventName = 'StopProcessing'

# Start a thread job that performs operations in the background.
$jb = 
  Start-ThreadJob {
    # ... Do things indefinitely, until the event of interest arrives
    #     and signals the desire to exit.
    # NOTE: Get-Event -SourceIdentifier $using:eventName *should* work, but
    #       as of v7.4.1 *doesn't*: see https://github.com/PowerShell/PowerShell/issues/11705
    while (-not (Get-Event).Where({ $_.SourceIdentifier -eq $using:eventName }, 'First')) {
      # ... do work here.
      '.' # Sample output.
      Start-Sleep 1
    }
    # Write an information message and exit.
    Write-Information 'Exit requested.'
  }
# Get the thread job's runspace, assumed to be the most recently created.
# Note:
#  Ideally, the runspace reference would be determined via the thread-info 
#  object, but I'm not aware of a way to do that.
$rs = Get-Runspace | Select-Object -Last 1

Write-Verbose -Verbose @"
A sample thread job is now running indefinitely, producing output in the
background that must be collected explicitly later, with Receive-Object.
The sample job outputs a "." for every second of its runtime.

Press ENTER to signal it to stop via a custom event,
and then collect its output:
"@
Read-Host

# Generate custom event in the thread job's runspace.
# Note: For the .Events property on the runspace to be populated, the job must
#       apparently be in the Running state, so we must wait for that.
#       (Due to the Read-Host above, this is unlikely to be necessary here,
#       but applies in general.)
while ($jb.State -ne 'Running') { Start-Sleep -Milliseconds 100 }
# Note: This is the equivalent of calling New-Event -SourceIdentifier $eventName,
#       only triggered runspace-externally.
$null = $rs.Events.GenerateEvent(
  $eventName,  # sourceIdentifier (event name)
  $null,       # event sender - not needed here
  $null,       # event arguments - not needed here
  $null        # additional data - not needed here.
)

# The job is now assumed to have received the event and exited.
# Collect the results now and output them.
# Note: 6>&1 is used to surface Write-Information's output.
Write-Verbose -Verbose "Collecting and outputting job results..."
$jb | Receive-Job -Wait -AutoRemoveJob 6>&1