检测管道中外部程序已退出的方法

问题描述 投票:0回答:1

在类似的管道中

GenerateLotsOfText | external-program | ConsumeExternalProgramOutput

当外部程序退出时,管道将继续运行,直到GenerateLotsOfText完成。假设外部程序仅生成一行输出,则

GenerateLotsOfText | external-program | Select-Object -First 1 | ConsumeExternalProgramOutput

将从外部程序生成输出的那一刻起停止整个管道。这是我正在寻找的行为,但需要注意的是,当外部程序不生成输出但提前退出时(例如由于 ctrl-c),管道仍然继续运行。因此,我现在正在寻找一种好方法来检测何时发生这种情况,并在发生这种情况时终止管道。

似乎可以编写一个使用

System.Diagnostics.Process
的 cmdlet,然后使用
Register-ObjectEvent
来侦听“退出”事件,但这涉及 I/O 流等的处理,所以我宁愿找到另一个方式。

我认为几乎所有其他 shell 都通过

||
&&
内置了“程序退出时生成输出”,这确实有效:

GenerateLotsOfText | cmd /c '(external-program && echo FOO) || echo FOO' | Select-Object -First 1 | FilterOutFooString | ConsumeExternalProgramOutput

因此,无论外部程序做什么,当它退出时总会产生一行 FOO,因此管道将立即停止(然后 FilterOutFooString 只负责产生实际输出)。这不是特别“好”,并且有额外的开销,因为一切都需要通过 cmd 进行管道传输(或者我认为任何其他 shell 也可以工作)。我希望 管道链运算符 本身允许这样做,但他们似乎不允许:尝试相同的语法会导致

Expressions are only allowed as the first element of a pipeline.
链接确实按预期工作,只是不在管道中,例如这会产生前面提到的 ParserError:

GenerateLotsOfText | ((external-program && echo FOO) || echo FOO) | Select-Object -First 1

还有其他方法可以实现这一点吗?

更新其他可能的方法:

  • 使用单独的运行空间来轮询运行外部命令的运行空间中的
    $LASTEXITCODE
    。不过,没有找到一种以线程安全的方式做到这一点的方法(例如,当管道运行时,无法从另一个线程调用
    $otherRunspace.SessionStateProxy.GetVariable('LASTEXITCODE')
  • 同样的想法,但要进行其他轮询:在
    NativeCommandProcessor
    中可以看到,一旦外部进程退出,它将在父管道上设置
    ExecutionFailed
    失败标志,但我没有找到访问该标志的方法标志,更不用说以线程安全的方式了
  • 我可能会使用 SteppablePipeline 做一些事情。如果我正确地得到它,它会得到一个允许手动执行管道迭代的对象。这将允许在迭代之间检查
    $LASTEXITCODE

在实现了最后一个非常简单的想法之后对此进行了研究,以上都不是一个选项:进程退出代码基本上只有在管道的

end
块运行后才能确定,即在上游管道元素生成之后它的所有输出

powershell shell
1个回答
1
投票

需要明确的是:正如最初针对 Unix 以及随后针对 Windows 所报告的那样,PowerShell 的当前行为(从 v7.4.x 开始)应被视为一个 bug,有望得到修复:PowerShell 应检测何时本机(外部程序)已退出,应停止该事件中的所有上游命令。


以下显示了通用解决方法,但是,它总是减慢速度并且有限制

  • 从 v7.4.x 开始,不幸的是,PowerShell 不提供公共功能来停止上游命令(请参阅GitHub 功能请求#3821);在内部,它使用 non-public 异常类型,例如
    Select-Object -First
    使用的异常类型。下面的函数使用 reflection 和临时编译的 C# 代码来抛出此异常(也如 this answer 中所示)。这意味着在会话中首次调用该函数时会付出编译性能损失。

定义函数

nw
(“原生包装器”),其源代码如下,然后按如下方式调用它,例如:

  • 在 Windows 上(假定 WSL;省略

    -Verbose
    以抑制详细输出):

    1..1e8 | nw wsl head -n 10 -Verbose
    
  • 在类 Unix 平台上(省略

    -Verbose
    以抑制详细输出):

    1..1e8 | nw head -n 10 -Verbose
    

您将看到上游命令(大型输入数组的枚举)在

head
终止时终止(出于所述原因,上游命令的终止会在会话中首次调用时导致性能损失)。

实施说明

  • nw
    被实现为代理(包装器)函数,使用可步进管道 - 有关更多信息,请参阅此答案
function nw {
  [CmdletBinding(PositionalBinding = $false)]
  param(
    [Parameter(Mandatory, ValueFromRemainingArguments)]
    [string[]] $ExeAndArgs
    ,
    [Parameter(ValueFromPipeline)]
    $InputObject
  )
  
  begin {

    # Split the arguments into executable name and its arguments.
    $exe, $exeArgs = $ExeAndArgs

    # Determine the process name matching the executable.
    $exeProcessName = [IO.Path]::GetFileNameWithoutExtension($exe)

    # The script block to use with the steppable pipeline.
    # Simply invoke the external program, and PowerShell will pipe input
    # to it when `.Process($_)` is called on the steppable pipeline in the `process` block,
    # including automatic formatting (implicit Out-String -Stream) for non-string input objects.
    # Also, $LASTEXITCODE is set as usual.
    $scriptCmd = { & $exe $exeArgs }

    # Create the steppable pipeline.
    try {
      $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
      # Record the time just before the external process is created.
      $beforeProcessCreation = [datetime]::Now
      # Start the pipeline, which creates the process for the external program
      # (launches it) - but note that when this call returns the
      # the process is *not* guaranteed to exist yet.
      $steppablePipeline.Begin($PSCmdlet)
    }
    catch {
      throw # Launching the external program failed.
    }
    # Get a reference to the newly launched process by its name and start time.
    # Note: This isn't foolproof, but probably good enough in practice.
    # Ideally, we'd also filter by having the current process as the parent process,
    # but that would require extra effort (a Get-CimInstance call on Windows, a `ps` call on Unix)
    $i = 0
    while (-not ($ps = (Get-Process -ErrorAction Ignore $exeProcessName | Where-Object StartTime -GT $beforeProcessCreation | Select-Object -First 1))) {
      if (++$i -eq 61) { throw "A (new) process named '$exeProcessName' unexpectedly did not appear with in the timeout period or exited right away." }
      Start-Sleep -Milliseconds 50
    }
  }
  
  process {
    
    # Check if the process has exited prematurely.
    if ($ps.HasExited) {

      # Note: $ps.ExitCode isn't available yet (even $ps.WaitForExit() wouldn't help), 
      #       so we cannot use it in the verbose message.
      #       However, $LASTEXITCODE should be set correctly afterwards.
      Write-Verbose "Process '$exeProcessName' has exited prematurely; terminating upstream commands (performance penalty on first call in a session)..."
      
      $steppablePipeline.End()

      # Throw the private exception that stops the upstream pipeline
      # !! Even though the exception type can be obtained and instantiated in
      # !! PowerShell code as follows:
      # !!   [System.Activator]::CreateInstance([psobject].assembly.GetType('System.Management.Automation.StopUpstreamCommandsException'), $PSCmdlet)
      # !! it cannot be *thrown* in a manner that *doesn't* 
      # !! result in a *PowerShell error*. Hence, unfortunately, ad-hoc compilation
      # !! of C# code is required, which incurs a performance penalty on first call in a given session.
    (Add-Type -PassThru -TypeDefinition '
      using System.Management.Automation;
      namespace net.same2u.PowerShell {
        public static class CustomPipelineStopper {
          public static void Stop(Cmdlet cmdlet) {
            throw (System.Exception) System.Activator.CreateInstance(typeof(Cmdlet).Assembly.GetType("System.Management.Automation.StopUpstreamCommandsException"), cmdlet);
          }
        }
    }')::Stop($PSCmdlet)
    
    }

    # Pass the current pipeline input object to the target process
    # via the steppable pipeline.
    $steppablePipeline.Process($_)

  }
  
  end {
    $steppablePipeline.End()
  }
}
© www.soinside.com 2019 - 2024. All rights reserved.